diff --git a/docs/STRUCTURED-VIEW.md b/docs/STRUCTURED-VIEW.md index 8114a07..db6902e 100644 --- a/docs/STRUCTURED-VIEW.md +++ b/docs/STRUCTURED-VIEW.md @@ -173,8 +173,10 @@ choices, not daemon ones. - 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. + source for composer `/` autocomplete at any whitespace-delimited token under + the caret. Completing a command replaces only that token. The client 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 c1eb51a..bcf58f4 100644 --- a/web/src/structured.test.ts +++ b/web/src/structured.test.ts @@ -462,6 +462,62 @@ describe("structured connection: turn/composer gating", () => { conn.dispose(); }); + it("completes the slash token under the caret without replacing surrounding text", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [ + commandNotice([ + { name: "security-review", description: "Review pending changes" }, + { name: "status", description: "Show status" }, + { name: "blitz", description: "Complete a milestone" }, + ]), + ], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input")!; + input.value = "asd /s dsa"; + input.setSelectionRange(6, 6); + input.dispatchEvent(new Event("input")); + + const options = conn.el.querySelectorAll(".structured-command-option"); + expect([...options].map((option) => option.querySelector(".structured-command-name")!.textContent)).toEqual([ + "/security-review", + "/status", + ]); + options[0].click(); + expect(input.value).toBe("asd /security-review dsa"); + expect(input.selectionStart).toBe(input.value.indexOf("dsa")); + expect(input.selectionEnd).toBe(input.selectionStart); + conn.dispose(); + }); + + it("replaces the rest of a partially edited slash token after the caret", () => { + const ws = new FakeSocket(); + const conn = mount({ sockets: [ws] }); + ws.open(); + ws.emit({ + type: "transcript_snapshot", + snapshot: { + cursor: 1, + rows: [commandNotice([{ name: "status", description: "Show status" }])], + }, + }); + ws.emit({ type: "reduced_state", state: idleState }); + const input = conn.el.querySelector(".structured-input")!; + input.value = "before /stale after"; + input.setSelectionRange(9, 9); + input.dispatchEvent(new Event("input")); + conn.el.querySelector(".structured-command-option")!.click(); + expect(input.value).toBe("before /status after"); + 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 580511f..f6e6881 100644 --- a/web/src/structured.ts +++ b/web/src/structured.ts @@ -50,6 +50,12 @@ interface AvailableCommand { input?: { hint?: string }; } +interface CommandToken { + start: number; + end: number; + query: string; +} + export function createStructuredConnection( sessionId: Uuid, opts: StructuredOptions = {}, @@ -443,25 +449,44 @@ export function createStructuredConnection( input.removeAttribute("aria-activedescendant"); } - function commandQuery(): string | null { - const match = /^\/([^\s]*)$/.exec(input.value); - return match ? match[1].toLocaleLowerCase() : null; + function commandTokenAtCursor(): CommandToken | null { + const cursor = input.selectionStart; + if (cursor !== input.selectionEnd) return null; + let start = cursor; + while (start > 0 && !/\s/.test(input.value[start - 1])) start--; + let end = cursor; + while (end < input.value.length && !/\s/.test(input.value[end])) end++; + const match = /^\/([^\s/]*)$/.exec(input.value.slice(start, cursor)); + return match ? { start, end, query: match[1].toLocaleLowerCase() } : null; } function chooseCommand(command: AvailableCommand): void { - input.value = `/${command.name} `; + const token = commandTokenAtCursor(); + if (!token) return; + const before = input.value.slice(0, token.start); + const after = input.value.slice(token.end); + let replacement = `/${command.name}`; + let cursor = before.length + replacement.length; + if (after.length === 0) { + replacement += " "; + cursor++; + } else if (/^[ \t]/.test(after)) { + cursor++; + } + input.value = before + replacement + after; closeCommandMenu(); autoResize(); input.focus(); - input.setSelectionRange(input.value.length, input.value.length); + input.setSelectionRange(cursor, cursor); } function renderCommandMenu(): void { - const query = commandQuery(); - if (query === null || input.disabled || availableCommands.length === 0) { + const token = commandTokenAtCursor(); + if (!token || input.disabled || availableCommands.length === 0) { closeCommandMenu(); return; } + const { query } = token; filteredCommands = availableCommands .filter((command) => command.name.toLocaleLowerCase().includes(query)) .sort((a, b) => { @@ -520,6 +545,11 @@ export function createStructuredConnection( activeCommandIndex = 0; renderCommandMenu(); }); + input.addEventListener("click", renderCommandMenu); + input.addEventListener("select", renderCommandMenu); + input.addEventListener("keyup", (event) => { + if (["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) renderCommandMenu(); + }); input.addEventListener("keydown", (e) => { if (!commandMenu.hidden) { if (e.key === "ArrowDown" || e.key === "ArrowUp") {