feat: autocomplete structured commands
This commit is contained in:
@@ -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`),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user