fix: autocomplete at composer caret

This commit is contained in:
Miguel Palhas
2026-09-06 15:52:04 +01:00
parent c355f67133
commit c3d008d07f
3 changed files with 97 additions and 9 deletions
+4 -2
View File
@@ -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`),
+56
View File
@@ -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<HTMLTextAreaElement>(".structured-input")!;
input.value = "asd /s dsa";
input.setSelectionRange(6, 6);
input.dispatchEvent(new Event("input"));
const options = conn.el.querySelectorAll<HTMLElement>(".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<HTMLTextAreaElement>(".structured-input")!;
input.value = "before /stale after";
input.setSelectionRange(9, 9);
input.dispatchEvent(new Event("input"));
conn.el.querySelector<HTMLElement>(".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] });
+37 -7
View File
@@ -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") {