diff --git a/src/interfaces/tui/controller.ts b/src/interfaces/tui/controller.ts index 609d172..95b0c90 100644 --- a/src/interfaces/tui/controller.ts +++ b/src/interfaces/tui/controller.ts @@ -7,7 +7,7 @@ */ import { Roles } from "@zesdex/domain"; import type { AppStateRest } from "./state.ts"; -import { AutocompleteKind, markDirty, toastInfo, overlayIsActive } from "./state.ts"; +import { AutocompleteKind, COMMANDS, markDirty, toastInfo, overlayIsActive } from "./state.ts"; import type { Action } from "./action.ts"; import { applyCommand, parseCommand } from "./command.ts"; import { resolveMenu } from "./menu.ts"; @@ -259,7 +259,17 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] { return []; case KeyCode.Enter: if (state.input.autocompleteVisible) { - state.input.selectAutocomplete(); + const wasCommand = state.input.autocompleteKind === AutocompleteKind.Command; + const selected = state.input.selectAutocomplete(); + // Completing a command via Enter should also RUN it (single Enter), + // unless the user typed more args after the token (e.g. "/edit foo"). + if (selected && wasCommand) { + const line = state.input.buffer.trim(); + const word = line.split(/\s+/)[0] ?? ""; + if (COMMANDS.includes(word)) { + return applyCommand(parseCommand(line)); + } + } return mark(state); } if (overlayIsActive(state.misc.overlay)) { diff --git a/src/interfaces/tui/tui.test.ts b/src/interfaces/tui/tui.test.ts index 25191b9..382b64c 100644 --- a/src/interfaces/tui/tui.test.ts +++ b/src/interfaces/tui/tui.test.ts @@ -94,20 +94,17 @@ describe("handleKey", () => { expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]); }); - test("autocomplete: partial /mo + Enter completes to /model", () => { + test("autocomplete: partial /mo + Enter completes and runs /model", () => { const s = freshState(); for (const c of "/mo") handleKey({ code: "Char", value: c, ctrl: false, alt: false }, s); expect(s.input.autocompleteVisible).toBe(true); expect(s.input.autocompleteCandidates).toContain("/model"); - // Enter first completes the token (closes dropdown), not fires an unknown - // command or clears the buffer. + // A single Enter on a matching autocomplete completes the token AND runs + // the command (no unknown-command error, no clear, no double-Enter). const first = handleKey({ code: "Enter", ctrl: false, alt: false }, s); expect(s.input.buffer).toBe("/model"); expect(s.input.autocompleteVisible).toBe(false); - expect(first).toEqual([]); - // A second Enter actually submits the completed command. - const second = handleKey({ code: "Enter", ctrl: false, alt: false }, s); - expect(second).toEqual([{ tag: "OpenMenuById", menu: "model" }]); + expect(first).toEqual([{ tag: "OpenMenuById", menu: "model" }]); }); test("autocomplete: full /model hides dropdown so one Enter runs it", () => { diff --git a/src/interfaces/tui/ui.tsx b/src/interfaces/tui/ui.tsx index 87a3d73..4d5397e 100644 --- a/src/interfaces/tui/ui.tsx +++ b/src/interfaces/tui/ui.tsx @@ -337,20 +337,31 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R useKeyboard((key: OpenTuiKey) => { if (key.eventType === "release") return; const zk = toControllerKey(key); + let consumedByModal = false; try { const actions: Action[] = handleKey(zk, state); for (const act of actions) { if (act.tag === "QuitConfirm") { state.quit = true; onQuit(); + consumedByModal = true; continue; } + // A slash-command that opens a modal (overlay / menu) "consumes" the + // input line — clear the buffer so `/model` doesn't linger. Autocomplete + // selection and normal submit must NOT be cleared (they own the buffer). + const opensModal = + act.tag === "OpenOverlay" || + act.tag === "OpenMenuById" || + act.tag === "OpenEditor" || + act.tag === "ShowDiff" || + act.tag === "ModelList"; + if (opensModal && !state.input.autocompleteVisible) consumedByModal = true; applyAction(state, act); } } finally { - // Clear input buffer after command dispatch (commands like /model, /help - // open overlays but don't clear the buffer via submit()). - if (zk.code === KeyCode.Enter && state.input.buffer.length > 0) { + // Clear the input line only when a modal command consumed it this press. + if (consumedByModal) { state.input.clear(); } // Force React to re-read the mutated `state` on the next frame. @@ -439,6 +450,7 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R void }): R > - {/* Autocomplete dropdown (shown above the input row) */} + {/* Dropdown (compact, sized to content) + activity + input, in order. The + input row is flexShrink:0 so the transcript scrollbox absorbs overflow + and the text area is never hidden. */} {state.input.autocompleteVisible && } - {/* Live activity breadcrumb of the running turn */} @@ -553,18 +566,16 @@ function InputView(props: { state: AppStateRest; width: number }): ReactNode { return ( {`─`.repeat(Math.max(0, width))} - + {` ❯ `} {hasContent ? ( - {shown} + {shown} ) : ( - - Ask Zesdex to help… - + Ask Zesdex to help… )} - {` ▏`} + {` ▏`} ); @@ -579,24 +590,24 @@ function AutocompleteView(props: { state: AppStateRest }): ReactNode { const title = kind === "command" ? "commands" : "files"; const selected = Math.min(state.input.autocompleteIdx, candidates.length - 1); const visible = candidates.slice(0, 10); - // Render the whole panel as ONE multiline — the OpenTUI box renders a - // single text node reliably, whereas sibling children can overlap. - const body = [ + // Render the whole panel as ONE multiline — this fixes the OpenTUI + // quirk where sibling children overlap. The box is sized to its + // content (flexShrink 0, explicit height) so it never squashes the input row. + const lines = [ ` ${title}`, - ...visible.map((c, i) => - i === selected ? `▸ ${c}` : ` ${c}`, - ), + ...visible.map((c, i) => (i === selected ? `▸ ${c}` : ` ${c}`)), ` ↑/↓ navigate · Tab accept · Esc close`, - ].join("\n"); + ]; + const body = lines.join("\n"); return ( - + {body}