feat(tui): enhance command execution on autocomplete and improve UI responsiveness

This commit is contained in:
asepharyana
2026-09-03 16:13:29 +07:00
parent 008f737e1f
commit f5b04214cf
3 changed files with 47 additions and 29 deletions
+12 -2
View File
@@ -7,7 +7,7 @@
*/ */
import { Roles } from "@zesdex/domain"; import { Roles } from "@zesdex/domain";
import type { AppStateRest } from "./state.ts"; 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 type { Action } from "./action.ts";
import { applyCommand, parseCommand } from "./command.ts"; import { applyCommand, parseCommand } from "./command.ts";
import { resolveMenu } from "./menu.ts"; import { resolveMenu } from "./menu.ts";
@@ -259,7 +259,17 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
return []; return [];
case KeyCode.Enter: case KeyCode.Enter:
if (state.input.autocompleteVisible) { 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); return mark(state);
} }
if (overlayIsActive(state.misc.overlay)) { if (overlayIsActive(state.misc.overlay)) {
+4 -7
View File
@@ -94,20 +94,17 @@ describe("handleKey", () => {
expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]); 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(); const s = freshState();
for (const c of "/mo") handleKey({ code: "Char", value: c, ctrl: false, alt: false }, s); for (const c of "/mo") handleKey({ code: "Char", value: c, ctrl: false, alt: false }, s);
expect(s.input.autocompleteVisible).toBe(true); expect(s.input.autocompleteVisible).toBe(true);
expect(s.input.autocompleteCandidates).toContain("/model"); expect(s.input.autocompleteCandidates).toContain("/model");
// Enter first completes the token (closes dropdown), not fires an unknown // A single Enter on a matching autocomplete completes the token AND runs
// command or clears the buffer. // the command (no unknown-command error, no clear, no double-Enter).
const first = handleKey({ code: "Enter", ctrl: false, alt: false }, s); const first = handleKey({ code: "Enter", ctrl: false, alt: false }, s);
expect(s.input.buffer).toBe("/model"); expect(s.input.buffer).toBe("/model");
expect(s.input.autocompleteVisible).toBe(false); expect(s.input.autocompleteVisible).toBe(false);
expect(first).toEqual([]); expect(first).toEqual([{ tag: "OpenMenuById", menu: "model" }]);
// 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", () => { test("autocomplete: full /model hides dropdown so one Enter runs it", () => {
+31 -20
View File
@@ -337,20 +337,31 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
useKeyboard((key: OpenTuiKey) => { useKeyboard((key: OpenTuiKey) => {
if (key.eventType === "release") return; if (key.eventType === "release") return;
const zk = toControllerKey(key); const zk = toControllerKey(key);
let consumedByModal = false;
try { try {
const actions: Action[] = handleKey(zk, state); const actions: Action[] = handleKey(zk, state);
for (const act of actions) { for (const act of actions) {
if (act.tag === "QuitConfirm") { if (act.tag === "QuitConfirm") {
state.quit = true; state.quit = true;
onQuit(); onQuit();
consumedByModal = true;
continue; 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); applyAction(state, act);
} }
} finally { } finally {
// Clear input buffer after command dispatch (commands like /model, /help // Clear the input line only when a modal command consumed it this press.
// open overlays but don't clear the buffer via submit()). if (consumedByModal) {
if (zk.code === KeyCode.Enter && state.input.buffer.length > 0) {
state.input.clear(); state.input.clear();
} }
// Force React to re-read the mutated `state` on the next frame. // 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
<scrollbox <scrollbox
flexGrow={1} flexGrow={1}
flexShrink={1} flexShrink={1}
flexBasis={0}
minHeight={0} minHeight={0}
style={{ backgroundColor: C.bg }} style={{ backgroundColor: C.bg }}
stickyScroll stickyScroll
@@ -446,9 +458,10 @@ export function ZesdexApp(props: { state: AppStateRest; onQuit: () => void }): R
> >
<TranscriptView state={state} width={width} /> <TranscriptView state={state} width={width} />
</scrollbox> </scrollbox>
{/* 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 && <AutocompleteView state={state} />} {state.input.autocompleteVisible && <AutocompleteView state={state} />}
{/* Live activity breadcrumb of the running turn */}
<ActivityBar state={state} running={running} /> <ActivityBar state={state} running={running} />
<InputView state={state} width={width} /> <InputView state={state} width={width} />
</box> </box>
@@ -553,18 +566,16 @@ function InputView(props: { state: AppStateRest; width: number }): ReactNode {
return ( return (
<box flexDirection="column" width="100%" flexShrink={0}> <box flexDirection="column" width="100%" flexShrink={0}>
<text fg={C.border}>{`─`.repeat(Math.max(0, width))}</text> <text fg={C.border}>{`─`.repeat(Math.max(0, width))}</text>
<box flexDirection="row" width="100%" flexShrink={0}> <box flexDirection="row" width="100%">
<text fg={C.accent}> <text fg={C.accent}>
<b>{` ❯ `}</b> <b>{` ❯ `}</b>
</text> </text>
{hasContent ? ( {hasContent ? (
<text fg={C.fg} flexGrow={1}>{shown}</text> <text fg={C.fg}>{shown}</text>
) : ( ) : (
<text fg={C.dim} flexGrow={1}> <text fg={C.dim}>Ask Zesdex to help…</text>
Ask Zesdex to help…
</text>
)} )}
<text fg={C.accent} flexShrink={0}>{` ▏`}</text> <text fg={C.accent}>{` ▏`}</text>
</box> </box>
</box> </box>
); );
@@ -579,24 +590,24 @@ function AutocompleteView(props: { state: AppStateRest }): ReactNode {
const title = kind === "command" ? "commands" : "files"; const title = kind === "command" ? "commands" : "files";
const selected = Math.min(state.input.autocompleteIdx, candidates.length - 1); const selected = Math.min(state.input.autocompleteIdx, candidates.length - 1);
const visible = candidates.slice(0, 10); const visible = candidates.slice(0, 10);
// Render the whole panel as ONE multiline <text> — the OpenTUI box renders a // Render the whole panel as ONE multiline <text> — this fixes the OpenTUI
// single text node reliably, whereas sibling <text> children can overlap. // quirk where sibling <text> children overlap. The box is sized to its
const body = [ // content (flexShrink 0, explicit height) so it never squashes the input row.
const lines = [
` ${title}`, ` ${title}`,
...visible.map((c, i) => ...visible.map((c, i) => (i === selected ? `▸ ${c}` : ` ${c}`)),
i === selected ? `▸ ${c}` : ` ${c}`,
),
` ↑/↓ navigate · Tab accept · Esc close`, ` ↑/↓ navigate · Tab accept · Esc close`,
].join("\n"); ];
const body = lines.join("\n");
return ( return (
<box <box
width="100%" width="100%"
flexDirection="column" flexDirection="column"
flexGrow={1} flexGrow={0}
flexShrink={0} flexShrink={0}
style={{ border: true, borderColor: C.border, backgroundColor: C.surface, padding: 1 }} style={{ border: true, borderColor: C.border, backgroundColor: C.surface, padding: 1 }}
> >
<text fg={kind === "command" ? C.accent : C.magenta}> <text fg={kind === "command" ? C.accent : C.magenta} height={lines.length}>
{body} {body}
</text> </text>
</box> </box>