feat: autocomplete structured commands

This commit is contained in:
Miguel Palhas
2026-09-06 15:37:49 +01:00
parent b5c9970e28
commit c355f67133
6 changed files with 327 additions and 4 deletions
+3
View File
@@ -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`),
+80
View File
@@ -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: "<milestone>" },
]),
],
},
});
ws.emit({ type: "reduced_state", state: idleState });
const input = conn.el.querySelector<HTMLTextAreaElement>(".structured-input")!;
const menu = conn.el.querySelector<HTMLElement>(".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("<milestone>");
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<HTMLTextAreaElement>(".structured-input")!;
input.value = "/hum";
input.dispatchEvent(new Event("input"));
const options = conn.el.querySelectorAll<HTMLElement>(".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] });
+142 -2
View File
@@ -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(
<div class="structured-rows"></div>
</div>
<form class="structured-composer">
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"></textarea>
<div class="structured-input-wrap">
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
role="listbox" hidden></div>
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
autocomplete="off" aria-autocomplete="list" aria-expanded="false"
aria-controls="structured-command-menu-${sessionId}"></textarea>
</div>
<button type="submit" class="btn btn-primary structured-send">Send</button>
<button type="button" class="btn structured-cancel" hidden>Cancel turn</button>
</form>
@@ -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<string, HTMLElement>();
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<AvailableCommand>;
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;
+9
View File
@@ -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();
});
+17 -1
View File
@@ -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<TranscriptRow["kind"], "plan"> },
row: TranscriptRow & { kind: "notice" },
approval?: ApprovalRowState,
toolExpanded?: boolean,
onToolExpandedChange?: (expanded: boolean) => void,
): HTMLElement | null;
export function buildTranscriptRow(
row: TranscriptRow & { kind: Exclude<TranscriptRow["kind"], "plan" | "notice"> },
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;
+76 -1
View File
@@ -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);