feat(tui): enhance command execution on autocomplete and improve UI responsiveness
This commit is contained in:
@@ -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)) {
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user