hermes-agent/ui-tui/src/domain/slash.ts
xxxigm 58ad6942d9
fix(tui): don't make Enter swallow trailing-space-only slash completions (#48425)
* fix(tui): don't make Enter swallow trailing-space-only slash completions

Submitting a slash command in the TUI took three Enter presses: one to
complete the name (/ex → /exit), a second that only appended the trailing
space the gateway adds to keep the classic-CLI prompt_toolkit dropdown open
(/exit → "/exit "), and a third to actually submit.

The composer's submit handler accepted the highlighted completion whenever
applying it changed the input at all, so the whitespace-only delta ate an
extra keypress. Treat a completion whose only change is trailing whitespace
on an already-complete token as "already complete" and fall through to
submit. Partial-name and argument completions (a real token change) still
accept on Enter as before.

The replace/accept logic is extracted into pure helpers (applyCompletion,
completionToApplyOnSubmit) in domain/slash.ts.

* test(tui): cover Enter/completion trailing-space behavior and isolate poller queue

- completionApply.test.ts asserts completionToApplyOnSubmit accepts real
  token completions (partial command name, argument) but returns null for a
  trailing-space-only delta on an already-complete command, so Enter submits
  instead of needing extra presses.
- test_notification_poller_delivers_completion / _skips_consumed previously
  shared the process-global process_registry.completion_queue. Their events
  carry no session_key, so a leaked/concurrent poller could dequeue and
  dispatch them to a fixture agent without run_conversation, flaking CI
  ("AttributeError: '_FakeAgent' object has no attribute 'run_conversation'").
  Isolate the queue per test (fresh queue.Queue via monkeypatch), matching the
  sibling poller tests that already do this.
2026-06-18 11:04:59 -05:00

50 lines
2 KiB
TypeScript

/** Appended to `/model` args from the TUI picker for session scope; stripped in `session` slash before `config.set`. */
export const TUI_SESSION_MODEL_FLAG = '--tui-session'
export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text)
export const parseSlashCommand = (cmd: string) => {
const [name = '', ...rest] = cmd.slice(1).split(/\s+/)
return { arg: rest.join(' '), cmd, name: name.toLowerCase() }
}
/**
* Apply a completion row to the current input, mirroring the editor's
* replace semantics: replace from `compReplace` with the row text, dropping
* the leading slash when both the input and the row carry one (the gateway's
* slash completer returns bare command names whose replace span begins after
* the leading `/`).
*/
export const applyCompletion = (value: string, rowText: string, compReplace: number): string => {
const text = value.startsWith('/') && rowText.startsWith('/') ? rowText.slice(1) : rowText
return value.slice(0, compReplace) + text
}
/**
* Decide what Enter does when a completion is highlighted: returns the value
* to set (accept the completion) or `null` to fall through to submit.
*
* Enter accepts a completion only when it changes the command/argument token.
* A completion that merely appends trailing whitespace to an already-complete
* command (e.g. `/exit` → `/exit `, the trailing space the gateway adds so the
* classic CLI's prompt_toolkit dropdown stays open) must NOT swallow the Enter
* — otherwise every slash command needs an extra keypress: type → Enter
* completes the name → Enter adds the space → Enter finally submits. Treating a
* whitespace-only delta as "already complete" collapses that back to the
* expected one/two presses.
*/
export const completionToApplyOnSubmit = (
value: string,
rowText: string | undefined,
compReplace: number
): string | null => {
if (!rowText) {
return null
}
const next = applyCompletion(value, rowText, compReplace)
return next !== value && next.trimEnd() !== value.trimEnd() ? next : null
}