From 008f737e1f5d0eef6a07ebe0fff2c9730bd44eb4 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 3 Sep 2026 14:26:46 +0700 Subject: [PATCH] feat(tui): enhance autocomplete functionality and UI interactions --- src/interfaces/tui/controller.ts | 10 ++++-- src/interfaces/tui/render.smoke.test.tsx | 11 ++++++ src/interfaces/tui/state.ts | 45 ++++++++++++++++++++---- src/interfaces/tui/tui.test.ts | 35 ++++++++++++++++++ src/interfaces/tui/ui.tsx | 35 +++++++++--------- 5 files changed, 111 insertions(+), 25 deletions(-) diff --git a/src/interfaces/tui/controller.ts b/src/interfaces/tui/controller.ts index 185d54c..609d172 100644 --- a/src/interfaces/tui/controller.ts +++ b/src/interfaces/tui/controller.ts @@ -216,8 +216,14 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] { } // ── Normal (non-overlay) dispatch ──────────────────────────────────── - // Slash commands bypass overlay guards — always process. - if (code === KeyCode.Enter && state.input.buffer.startsWith("/")) { + // Slash commands can run even when an overlay is open, BUT an open + // autocomplete dropdown has first claim on Enter: it completes the command + // token rather than firing a half-typed "/mo" as an unknown command. + if ( + code === KeyCode.Enter && + state.input.buffer.startsWith("/") && + !state.input.autocompleteVisible + ) { const text = state.input.buffer; return applyCommand(parseCommand(text)); } diff --git a/src/interfaces/tui/render.smoke.test.tsx b/src/interfaces/tui/render.smoke.test.tsx index 6512987..9e438ab 100644 --- a/src/interfaces/tui/render.smoke.test.tsx +++ b/src/interfaces/tui/render.smoke.test.tsx @@ -135,6 +135,17 @@ describe("ZesdexApp render smoke", () => { expect(frame).toContain("(active)"); }); + test("autocomplete dropdown renders command rows (not overlapping)", async () => { + const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); + s.input.buffer = "/mo"; + s.input.cursor = 3; + s.input.openAutocomplete(); + const frame = await render(s, 80, 20); + expect(frame).toContain("commands"); + expect(frame).toContain("/model"); + expect(frame).toContain("Tab accept"); + }); + test("interactive /config menu shows rows with current values", async () => { const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m"); s.settings = { provider: "zen", internet_mode: "Off", review_enabled: true }; diff --git a/src/interfaces/tui/state.ts b/src/interfaces/tui/state.ts index 33b7506..aa230e5 100644 --- a/src/interfaces/tui/state.ts +++ b/src/interfaces/tui/state.ts @@ -35,7 +35,7 @@ export const COMMANDS: string[] = [ "/help", "/quit", "/clear", - "/edit ", + "/edit", "/mcp", "/model", "/config", @@ -57,6 +57,8 @@ export class InputState { autocompleteVisible = false; autocompleteKind: AutocompleteKind = AutocompleteKind.Command; mentionStart = 0; + /** Start index of the command token being completed (for in-place replace). */ + autocompleteTokenStart = 0; historyFile: string | null = null; /** Hide the autocomplete dropdown and clear its state. */ @@ -66,6 +68,7 @@ export class InputState { this.autocompleteIdx = 0; this.autocompleteKind = AutocompleteKind.Command; this.mentionStart = 0; + this.autocompleteTokenStart = 0; } /** Open or refresh the autocomplete dropdown by filtering COMMANDS. */ @@ -75,11 +78,28 @@ export class InputState { this.closeAutocomplete(); return; } - const prefix = trimmed.toLowerCase(); - this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix)); + // Filter on the *command token* (the word before any space), so typing + // "/edit foo" still matches "/edit". A trailing/embedded space must not + // kill the dropdown. + const word = trimmed.split(/\s+/)[0] ?? ""; + const prefix = word.toLowerCase(); + if (!prefix.startsWith("/")) { + this.closeAutocomplete(); + return; + } + // Prefer exact-match-first so a fully-typed command jumps to the top. + this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix)).sort( + (a, b) => Number(a === word) - Number(b === word), + ); + // Record where the command token starts in the *raw buffer* (not the + // trimmed word) so selectAutocomplete can replace just that token. + this.autocompleteTokenStart = this.buffer.indexOf("/"); this.autocompleteKind = AutocompleteKind.Command; this.autocompleteIdx = 0; - this.autocompleteVisible = this.autocompleteCandidates.length > 0; + // A fully-typed command needs no dropdown — entering closes it so Enter + // runs the command immediately instead of "selecting" the same string. + const exact = this.autocompleteCandidates.includes(word); + this.autocompleteVisible = this.autocompleteCandidates.length > 0 && !exact; } /** Find the `@mention` token (if any) immediately before the cursor. */ @@ -130,13 +150,26 @@ export class InputState { } } + /** Index just past the command token (stops at a space or end of buffer). */ + private endOfToken(): number { + const start = this.autocompleteTokenStart; + if (start < 0 || start > this.buffer.length) return start; + const rest = this.buffer.slice(start); + const space = rest.search(/\s/); + return space === -1 ? this.buffer.length : start + space; + } + /** Accept the currently selected autocomplete candidate. Returns true on success. */ selectAutocomplete(): boolean { const candidate = this.autocompleteCandidates[this.autocompleteIdx]; if (candidate === undefined || candidate === null) return false; if (this.autocompleteKind === AutocompleteKind.Command) { - this.buffer = candidate; - this.cursor = this.buffer.length; + // Replace only the command token (e.g. "/mo" → "/model"), keeping any + // text the user already typed after it (e.g. "/edit path" stays). + const start = this.autocompleteTokenStart; + const end = this.endOfToken(); + this.buffer = this.buffer.slice(0, start) + candidate + this.buffer.slice(end); + this.cursor = start + candidate.length; } else { if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) { this.closeAutocomplete(); diff --git a/src/interfaces/tui/tui.test.ts b/src/interfaces/tui/tui.test.ts index 28d7205..25191b9 100644 --- a/src/interfaces/tui/tui.test.ts +++ b/src/interfaces/tui/tui.test.ts @@ -94,6 +94,41 @@ describe("handleKey", () => { expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]); }); + test("autocomplete: partial /mo + Enter completes to /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. + 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" }]); + }); + + test("autocomplete: full /model hides dropdown so one Enter runs it", () => { + const s = freshState(); + for (const c of "/model") handleKey({ code: "Char", value: c, ctrl: false, alt: false }, s); + expect(s.input.autocompleteVisible).toBe(false); // exact match → no dropdown + const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s); + expect(actions).toEqual([{ tag: "OpenMenuById", menu: "model" }]); + }); + + test("autocomplete: /edit path keeps its args on Enter (no clobber)", () => { + const s = freshState(); + for (const c of "/edit src/a.ts") handleKey({ code: "Char", value: c, ctrl: false, alt: false }, s); + expect(s.input.autocompleteVisible).toBe(false); // /edit matches exactly too + const actions = handleKey({ code: "Enter", ctrl: false, alt: false }, s); + // Should dispatch the /edit command with path, preserving the typed args. + const opened = actions.map((a) => a.tag); + expect(opened).toContain("OpenEditor"); + expect(s.input.buffer).toBe("/edit src/a.ts"); + }); + test("Ctrl+Y copies last assistant message", () => { const s = freshState(); pushTranscript(s, makeChatMessage(Roles.User, "hi")); diff --git a/src/interfaces/tui/ui.tsx b/src/interfaces/tui/ui.tsx index 62f8fd7..87a3d73 100644 --- a/src/interfaces/tui/ui.tsx +++ b/src/interfaces/tui/ui.tsx @@ -447,7 +447,7 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R {/* Autocomplete dropdown (shown above the input row) */} - {state.input.autocompleteVisible && } + {state.input.autocompleteVisible && } {/* Live activity breadcrumb of the running turn */} @@ -572,32 +572,33 @@ function InputView(props: { state: AppStateRest; width: number }): ReactNode { /* ── Autocomplete ─────────────────────────────────────────────────── */ -function AutocompleteView(props: { state: AppStateRest; width: number }): ReactNode { - const { state, width } = props; +function AutocompleteView(props: { state: AppStateRest }): ReactNode { + const { state } = props; const candidates = state.input.autocompleteCandidates; const kind = state.input.autocompleteKind; 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 = [ + ` ${title}`, + ...visible.map((c, i) => + i === selected ? `▸ ${c}` : ` ${c}`, + ), + ` ↑/↓ navigate · Tab accept · Esc close`, + ].join("\n"); return ( - - {` ${title}`} + + {body} - {visible.map((c, i) => ( - - {i === selected ? `${"▸ "}${c}${" ".repeat(Math.max(0, width - c.length - 4))}` : ` ${c}`} - - ))} - {` ↑/↓ navigate · Tab accept · Esc close`} ); }