fix: autocomplete at composer caret
This commit is contained in:
@@ -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`),
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user