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}