feat(tui): enhance autocomplete functionality and UI interactions
This commit is contained in:
@@ -216,8 +216,14 @@ export function handleKey(key: KeyEvent, state: AppStateRest): Action[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Normal (non-overlay) dispatch ────────────────────────────────────
|
// ── Normal (non-overlay) dispatch ────────────────────────────────────
|
||||||
// Slash commands bypass overlay guards — always process.
|
// Slash commands can run even when an overlay is open, BUT an open
|
||||||
if (code === KeyCode.Enter && state.input.buffer.startsWith("/")) {
|
// 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;
|
const text = state.input.buffer;
|
||||||
return applyCommand(parseCommand(text));
|
return applyCommand(parseCommand(text));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,17 @@ describe("ZesdexApp render smoke", () => {
|
|||||||
expect(frame).toContain("(active)");
|
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 () => {
|
test("interactive /config menu shows rows with current values", async () => {
|
||||||
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
const s = createTuiState(["/tmp/x"], "/tmp/x/s", "/tmp/x/m");
|
||||||
s.settings = { provider: "zen", internet_mode: "Off", review_enabled: true };
|
s.settings = { provider: "zen", internet_mode: "Off", review_enabled: true };
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const COMMANDS: string[] = [
|
|||||||
"/help",
|
"/help",
|
||||||
"/quit",
|
"/quit",
|
||||||
"/clear",
|
"/clear",
|
||||||
"/edit <path>",
|
"/edit",
|
||||||
"/mcp",
|
"/mcp",
|
||||||
"/model",
|
"/model",
|
||||||
"/config",
|
"/config",
|
||||||
@@ -57,6 +57,8 @@ export class InputState {
|
|||||||
autocompleteVisible = false;
|
autocompleteVisible = false;
|
||||||
autocompleteKind: AutocompleteKind = AutocompleteKind.Command;
|
autocompleteKind: AutocompleteKind = AutocompleteKind.Command;
|
||||||
mentionStart = 0;
|
mentionStart = 0;
|
||||||
|
/** Start index of the command token being completed (for in-place replace). */
|
||||||
|
autocompleteTokenStart = 0;
|
||||||
historyFile: string | null = null;
|
historyFile: string | null = null;
|
||||||
|
|
||||||
/** Hide the autocomplete dropdown and clear its state. */
|
/** Hide the autocomplete dropdown and clear its state. */
|
||||||
@@ -66,6 +68,7 @@ export class InputState {
|
|||||||
this.autocompleteIdx = 0;
|
this.autocompleteIdx = 0;
|
||||||
this.autocompleteKind = AutocompleteKind.Command;
|
this.autocompleteKind = AutocompleteKind.Command;
|
||||||
this.mentionStart = 0;
|
this.mentionStart = 0;
|
||||||
|
this.autocompleteTokenStart = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open or refresh the autocomplete dropdown by filtering COMMANDS. */
|
/** Open or refresh the autocomplete dropdown by filtering COMMANDS. */
|
||||||
@@ -75,11 +78,28 @@ export class InputState {
|
|||||||
this.closeAutocomplete();
|
this.closeAutocomplete();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const prefix = trimmed.toLowerCase();
|
// Filter on the *command token* (the word before any space), so typing
|
||||||
this.autocompleteCandidates = COMMANDS.filter((c) => c.startsWith(prefix));
|
// "/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.autocompleteKind = AutocompleteKind.Command;
|
||||||
this.autocompleteIdx = 0;
|
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. */
|
/** 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. */
|
/** Accept the currently selected autocomplete candidate. Returns true on success. */
|
||||||
selectAutocomplete(): boolean {
|
selectAutocomplete(): boolean {
|
||||||
const candidate = this.autocompleteCandidates[this.autocompleteIdx];
|
const candidate = this.autocompleteCandidates[this.autocompleteIdx];
|
||||||
if (candidate === undefined || candidate === null) return false;
|
if (candidate === undefined || candidate === null) return false;
|
||||||
if (this.autocompleteKind === AutocompleteKind.Command) {
|
if (this.autocompleteKind === AutocompleteKind.Command) {
|
||||||
this.buffer = candidate;
|
// Replace only the command token (e.g. "/mo" → "/model"), keeping any
|
||||||
this.cursor = this.buffer.length;
|
// 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 {
|
} else {
|
||||||
if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) {
|
if (this.cursor < this.mentionStart || this.mentionStart > this.buffer.length) {
|
||||||
this.closeAutocomplete();
|
this.closeAutocomplete();
|
||||||
|
|||||||
@@ -94,6 +94,41 @@ describe("handleKey", () => {
|
|||||||
expect(actions).toEqual([{ tag: "SubmitInput", text: "a" }]);
|
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", () => {
|
test("Ctrl+Y copies last assistant message", () => {
|
||||||
const s = freshState();
|
const s = freshState();
|
||||||
pushTranscript(s, makeChatMessage(Roles.User, "hi"));
|
pushTranscript(s, makeChatMessage(Roles.User, "hi"));
|
||||||
|
|||||||
+18
-17
@@ -447,7 +447,7 @@ 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) */}
|
{/* Autocomplete dropdown (shown above the input row) */}
|
||||||
{state.input.autocompleteVisible && <AutocompleteView state={state} width={width} />}
|
{state.input.autocompleteVisible && <AutocompleteView state={state} />}
|
||||||
{/* Live activity breadcrumb of the running turn */}
|
{/* 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} />
|
||||||
@@ -572,32 +572,33 @@ function InputView(props: { state: AppStateRest; width: number }): ReactNode {
|
|||||||
|
|
||||||
/* ── Autocomplete ─────────────────────────────────────────────────── */
|
/* ── Autocomplete ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function AutocompleteView(props: { state: AppStateRest; width: number }): ReactNode {
|
function AutocompleteView(props: { state: AppStateRest }): ReactNode {
|
||||||
const { state, width } = props;
|
const { state } = props;
|
||||||
const candidates = state.input.autocompleteCandidates;
|
const candidates = state.input.autocompleteCandidates;
|
||||||
const kind = state.input.autocompleteKind;
|
const kind = state.input.autocompleteKind;
|
||||||
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
|
||||||
|
// single text node reliably, whereas sibling <text> children can overlap.
|
||||||
|
const body = [
|
||||||
|
` ${title}`,
|
||||||
|
...visible.map((c, i) =>
|
||||||
|
i === selected ? `▸ ${c}` : ` ${c}`,
|
||||||
|
),
|
||||||
|
` ↑/↓ navigate · Tab accept · Esc close`,
|
||||||
|
].join("\n");
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
width="100%"
|
width="100%"
|
||||||
style={{
|
flexDirection="column"
|
||||||
border: true,
|
flexGrow={1}
|
||||||
borderColor: C.border,
|
flexShrink={0}
|
||||||
backgroundColor: C.surface,
|
style={{ border: true, borderColor: C.border, backgroundColor: C.surface, padding: 1 }}
|
||||||
padding: 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<text fg={C.magenta}>
|
<text fg={kind === "command" ? C.accent : C.magenta}>
|
||||||
<b>{` ${title}`}</b>
|
{body}
|
||||||
</text>
|
</text>
|
||||||
{visible.map((c, i) => (
|
|
||||||
<text key={c} fg={i === selected ? C.accent : C.fg}>
|
|
||||||
{i === selected ? `${"▸ "}${c}${" ".repeat(Math.max(0, width - c.length - 4))}` : ` ${c}`}
|
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
<text fg={C.dim}>{` ↑/↓ navigate · Tab accept · Esc close`}</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user