From c355f6713352d5e2b0bc682e505818962e62d4c6 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 6 Sep 2026 15:37:49 +0100 Subject: [PATCH] feat: autocomplete structured commands --- docs/STRUCTURED-VIEW.md | 3 + web/src/structured.test.ts | 80 +++++++++++++++++ web/src/structured.ts | 144 ++++++++++++++++++++++++++++++- web/src/structuredRender.test.ts | 9 ++ web/src/structuredRender.ts | 18 +++- web/src/styles.css | 77 ++++++++++++++++- 6 files changed, 327 insertions(+), 4 deletions(-) diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 684df5a..8114a07 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -172,6 +172,9 @@ choices, not daemon ones. content the adapter supplied. - The web client's user, agent, thought, tool-detail, and notice text blocks copy their complete source text when clicked or keyboard-activated. +- The web client consumes `available_commands_update` notices as the live + source for composer `/` autocomplete and omits those state-only notices, + along with `session_info_update`, from the visible transcript. - How `responding` vs. `thinking` are distinguished visually, if at all. - How the operator is asked to resolve an `Approval` row — the daemon only exposes the three-way decision (`AllowOnce` / `AllowForSession` / `Deny`), diff --git a/web/src/structured.test.ts b/web/src/structured.test.ts index e497b34..c1eb51a 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -382,6 +382,86 @@ describe("structured connection: data flow", () => { describe("structured connection: turn/composer gating", () => { beforeEach(() => vi.clearAllMocks()); + const commandNotice = (commands: Array<{ name: string; description: string; hint?: string }>) => + row("commands", "notice", { + sessionUpdate: "available_commands_update", + availableCommands: commands.map(({ name, description, hint }) => ({ + name, + description, + ...(hint ? { input: { hint } } : {}), + })), + }); + + it("autocompletes the live ACP command catalog with keyboard navigation", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [ + commandNotice([ + { name: "yolo", description: "Implement directly" }, + { name: "blitz", description: "Complete a milestone", hint: "" }, + ]), + ], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input")!; + const menu = conn.el.querySelector(".structured-command-menu")!; + input.value = "/"; + input.dispatchEvent(new Event("input")); + + expect(menu.hidden).toBe(false); + expect([...menu.querySelectorAll(".structured-command-name")].map((node) => node.textContent)).toEqual([ + "/blitz", + "/yolo", + ]); + expect(menu.querySelector(".structured-command-hint")!.textContent).toBe(""); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(input.value).toBe("/yolo "); + expect(menu.hidden).toBe(true); + expect(sendStructuredPrompt).not.toHaveBeenCalled(); + + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "/yolo "); + conn.dispose(); + }); + + it("filters commands as the operator types and accepts a touch selection", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [ + commandNotice([ + { name: "hourlog", description: "Reconcile hours" }, + { name: "humanizer", description: "Rewrite prose" }, + { name: "blitz", description: "Complete a milestone" }, + ]), + ], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input")!; + input.value = "/hum"; + input.dispatchEvent(new Event("input")); + const options = conn.el.querySelectorAll(".structured-command-option"); + expect(options).toHaveLength(1); + expect(options[0].textContent).toContain("/humanizer"); + options[0].click(); + expect(input.value).toBe("/humanizer "); + expect(document.activeElement).toBe(input); + conn.dispose(); + }); + it("disables the composer while a turn is active and re-enables when idle", () => { const ws = new FakeSocket(); const conn = mount({ sockets: [ws] }); diff --git a/web/src/structured.ts b/web/src/structured.ts index d14cc67..580511f 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -44,6 +44,12 @@ const idleTurn = (): ReducedState["turn"] => ({ active_tool_calls: [], }); +interface AvailableCommand { + name: string; + description: string; + input?: { hint?: string }; +} + export function createStructuredConnection( sessionId: Uuid, opts: StructuredOptions = {}, @@ -67,7 +73,13 @@ export function createStructuredConnection(
- +
+ + +
@@ -81,6 +93,7 @@ export function createStructuredConnection( const scrollEl = el.querySelector(".structured-scroll") as HTMLDivElement; const rowsEl = el.querySelector(".structured-rows") as HTMLDivElement; const composerForm = el.querySelector(".structured-composer") as HTMLFormElement; + const commandMenu = el.querySelector(".structured-command-menu") as HTMLDivElement; const input = el.querySelector(".structured-input") as HTMLTextAreaElement; const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement; const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement; @@ -108,6 +121,9 @@ export function createStructuredConnection( const groupEls = new Map(); let state: ReducedState = { turn: idleTurn() }; let pendingApprovalRowId: string | null = null; + let availableCommands: AvailableCommand[] = []; + let filteredCommands: AvailableCommand[] = []; + let activeCommandIndex = -1; let submissionGeneration = 0; let awaitingBeginGeneration: number | null = null; let acceptedSubmission: { generation: number; socketGeneration: number } | null = null; @@ -310,6 +326,7 @@ export function createStructuredConnection( rowIndex.clear(); rows.forEach((row, i) => rowIndex.set(row.id, i)); rebuildTranscript(); + syncAvailableCommands(); }, forceBottom); receivedSnapshot = true; } @@ -337,6 +354,7 @@ export function createStructuredConnection( rows.forEach((row, idx) => rowIndex.set(row.id, idx)); } rebuildTranscript(); + syncAvailableCommands(); }); } @@ -352,6 +370,7 @@ export function createStructuredConnection( input.disabled = active; sendBtn.disabled = active; cancelBtn.hidden = !state.turn.responding && !state.turn.thinking; + if (active) closeCommandMenu(); } function reconcileAwaitingBegin(): void { @@ -398,19 +417,140 @@ export function createStructuredConnection( } // ---- composer ---- + function commandsFromTranscript(): AvailableCommand[] { + for (let i = rows.length - 1; i >= 0; i--) { + if (rows[i].kind !== "notice") continue; + const data = rows[i].data as + | { sessionUpdate?: string; availableCommands?: unknown[] } + | undefined; + if (data?.sessionUpdate !== "available_commands_update") continue; + return (data.availableCommands ?? []).flatMap((value) => { + if (!value || typeof value !== "object") return []; + const command = value as Partial; + if (typeof command.name !== "string" || typeof command.description !== "string") return []; + return [{ name: command.name, description: command.description, input: command.input }]; + }); + } + return []; + } + + function closeCommandMenu(): void { + commandMenu.hidden = true; + commandMenu.replaceChildren(); + filteredCommands = []; + activeCommandIndex = -1; + input.setAttribute("aria-expanded", "false"); + input.removeAttribute("aria-activedescendant"); + } + + function commandQuery(): string | null { + const match = /^\/([^\s]*)$/.exec(input.value); + return match ? match[1].toLocaleLowerCase() : null; + } + + function chooseCommand(command: AvailableCommand): void { + input.value = `/${command.name} `; + closeCommandMenu(); + autoResize(); + input.focus(); + input.setSelectionRange(input.value.length, input.value.length); + } + + function renderCommandMenu(): void { + const query = commandQuery(); + if (query === null || input.disabled || availableCommands.length === 0) { + closeCommandMenu(); + return; + } + filteredCommands = availableCommands + .filter((command) => command.name.toLocaleLowerCase().includes(query)) + .sort((a, b) => { + const aStarts = a.name.toLocaleLowerCase().startsWith(query); + const bStarts = b.name.toLocaleLowerCase().startsWith(query); + return aStarts === bStarts ? a.name.localeCompare(b.name) : aStarts ? -1 : 1; + }); + if (filteredCommands.length === 0) { + closeCommandMenu(); + return; + } + activeCommandIndex = Math.min(Math.max(activeCommandIndex, 0), filteredCommands.length - 1); + const options = filteredCommands.map((command, index) => { + const option = document.createElement("div"); + option.id = `${commandMenu.id}-option-${index}`; + option.className = "structured-command-option"; + option.setAttribute("role", "option"); + option.setAttribute("aria-selected", String(index === activeCommandIndex)); + const title = document.createElement("div"); + title.className = "structured-command-title"; + const name = document.createElement("span"); + name.className = "structured-command-name"; + name.textContent = `/${command.name}`; + const hint = document.createElement("span"); + hint.className = "structured-command-hint"; + hint.textContent = command.input?.hint ?? ""; + title.append(name, hint); + const description = document.createElement("div"); + description.className = "structured-command-description"; + description.textContent = command.description.replace(/\s+/g, " ").trim(); + option.append(title, description); + option.addEventListener("pointerdown", (event) => event.preventDefault()); + option.addEventListener("click", () => chooseCommand(command)); + return option; + }); + commandMenu.replaceChildren(...options); + commandMenu.hidden = false; + input.setAttribute("aria-expanded", "true"); + input.setAttribute("aria-activedescendant", options[activeCommandIndex].id); + options[activeCommandIndex].scrollIntoView?.({ block: "nearest" }); + } + + function syncAvailableCommands(): void { + availableCommands = commandsFromTranscript(); + renderCommandMenu(); + } + function autoResize(): void { updateTranscriptLayout(() => { input.style.height = "auto"; input.style.height = `${Math.min(input.scrollHeight, 160)}px`; }); } - input.addEventListener("input", autoResize); + input.addEventListener("input", () => { + autoResize(); + activeCommandIndex = 0; + renderCommandMenu(); + }); input.addEventListener("keydown", (e) => { + if (!commandMenu.hidden) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const direction = e.key === "ArrowDown" ? 1 : -1; + activeCommandIndex = + (activeCommandIndex + direction + filteredCommands.length) % filteredCommands.length; + renderCommandMenu(); + return; + } + if ((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") { + e.preventDefault(); + chooseCommand(filteredCommands[activeCommandIndex]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + closeCommandMenu(); + return; + } + } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); composerForm.requestSubmit(); } }); + input.addEventListener("blur", () => { + window.setTimeout(() => { + if (!commandMenu.contains(document.activeElement)) closeCommandMenu(); + }, 0); + }); composerForm.addEventListener("submit", (e) => { e.preventDefault(); const text = input.value; diff --git a/web/src/structuredRender.test.ts b/web/src/structuredRender.test.ts index d0c5c9e..7c8d768 100644 --- a/web/src/structuredRender.test.ts +++ b/web/src/structuredRender.test.ts @@ -340,6 +340,15 @@ describe("buildTranscriptRow: notice / unknown kinds", () => { expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]"); }); + it("omits command-catalog and session-info notices used only as UI state", () => { + expect( + buildTranscriptRow( + row("notice", { sessionUpdate: "available_commands_update", availableCommands: [] }), + ), + ).toBeNull(); + expect(buildTranscriptRow(row("notice", { sessionUpdate: "session_info_update" }))).toBeNull(); + }); + it("returns no transcript element for a plan row", () => { expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull(); }); diff --git a/web/src/structuredRender.ts b/web/src/structuredRender.ts index bb12fa1..801aded 100644 --- a/web/src/structuredRender.ts +++ b/web/src/structuredRender.ts @@ -405,6 +405,12 @@ function buildPlainRow(row: TranscriptRow): HTMLElement { return wrap; } +function isStateOnlyNotice(row: TranscriptRow): boolean { + if (row.kind !== "notice" || !row.data || typeof row.data !== "object") return false; + const update = (row.data as { sessionUpdate?: string }).sessionUpdate; + return update === "available_commands_update" || update === "session_info_update"; +} + export interface ApprovalRowState { pending: boolean; /** Resolves once the decision has been submitted; rejects when the POST @@ -504,7 +510,13 @@ export function buildTranscriptRow( onToolExpandedChange?: (expanded: boolean) => void, ): null; export function buildTranscriptRow( - row: TranscriptRow & { kind: Exclude }, + row: TranscriptRow & { kind: "notice" }, + approval?: ApprovalRowState, + toolExpanded?: boolean, + onToolExpandedChange?: (expanded: boolean) => void, +): HTMLElement | null; +export function buildTranscriptRow( + row: TranscriptRow & { kind: Exclude }, approval?: ApprovalRowState, toolExpanded?: boolean, onToolExpandedChange?: (expanded: boolean) => void, @@ -541,6 +553,10 @@ export function buildTranscriptRow( break; case "plan": return null; + case "notice": + if (isStateOnlyNotice(row)) return null; + built = buildPlainRow(row); + break; default: built = buildPlainRow(row); break; diff --git a/web/src/styles.css b/web/src/styles.css index c3c3122..de1b908 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2499,6 +2499,10 @@ input.leaf-rename:focus { flex-direction: column; } +.st-tool-group-body > .st-row-tool { + padding: var(--sp-1) 0; +} + .st-tool-group-body[hidden] { display: none; } @@ -2668,9 +2672,16 @@ input.leaf-rename:focus { padding-bottom: max(var(--sp-3), env(safe-area-inset-bottom)); } -.structured-input { +.structured-input-wrap { + position: relative; flex: 1 1 auto; min-width: 0; +} + +.structured-input { + display: block; + width: 100%; + min-width: 0; max-height: 160px; resize: none; padding: var(--sp-2) var(--sp-3); @@ -2682,6 +2693,70 @@ input.leaf-rename:focus { font-size: var(--fs-13); } +.structured-command-menu { + position: absolute; + z-index: var(--z-sticky); + right: 0; + bottom: calc(100% + var(--sp-2)); + left: 0; + max-height: min(42vh, 320px); + overflow-y: auto; + border: 1px solid var(--border-strong); + border-radius: var(--r-2); + background: var(--surface); + box-shadow: var(--shadow-2); +} + +.structured-command-menu[hidden] { + display: none; +} + +.structured-command-option { + min-height: var(--tap); + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--border); + cursor: pointer; +} + +.structured-command-option:last-child { + border-bottom: 0; +} + +.structured-command-option:hover, +.structured-command-option[aria-selected="true"] { + background: var(--accent-tint); +} + +.structured-command-title { + display: flex; + align-items: baseline; + gap: var(--sp-2); + min-width: 0; +} + +.structured-command-name { + flex: 0 0 auto; + color: var(--ink); + font-weight: 600; +} + +.structured-command-hint, +.structured-command-description { + overflow: hidden; + color: var(--muted); + font-size: var(--fs-11); + text-overflow: ellipsis; + white-space: nowrap; +} + +.structured-command-hint { + min-width: 0; +} + +.structured-command-description { + margin-top: var(--sp-1); +} + .structured-input:focus { outline: none; border-color: var(--accent);