feat: add composer completion and mini task controls
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||||
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||||
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
|
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview",
|
"preview": "deno run -A npm:vite@7.2.7 preview",
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import ComposerInput from "./ComposerInput.svelte";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
function editor(container: HTMLElement) {
|
||||||
|
return EditorView.findFromDOM(
|
||||||
|
container.querySelector(".cm-editor") as HTMLElement,
|
||||||
|
)!;
|
||||||
|
}
|
||||||
|
function type(view: EditorView, value: string, cursor = value.length) {
|
||||||
|
view.focus();
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||||
|
selection: { anchor: cursor },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("commands auto-open with descriptions; ambiguous Tab does not choose the first; arrows beat history", async () => {
|
||||||
|
const oncommand = vi.fn();
|
||||||
|
const onsubmit = vi.fn();
|
||||||
|
const ui = render(ComposerInput, {
|
||||||
|
historyScope: "completion-test",
|
||||||
|
oncommand,
|
||||||
|
onsubmit,
|
||||||
|
});
|
||||||
|
const cm = editor(ui.container);
|
||||||
|
type(cm, ":");
|
||||||
|
await ui.findByRole("listbox");
|
||||||
|
expect(ui.getAllByRole("option")).toHaveLength(6);
|
||||||
|
expect(ui.getByText("Request immediate Worker context compaction.")).not
|
||||||
|
.toBeNull();
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Tab" });
|
||||||
|
expect(cm.state.doc.toString()).toBe(":");
|
||||||
|
expect(ui.getByRole("status").textContent).toContain("Select a command");
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowDown" });
|
||||||
|
expect(ui.getAllByRole("option")[0].getAttribute("aria-selected")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(cm.contentDOM.getAttribute("aria-activedescendant")).toBe(
|
||||||
|
ui.getAllByRole("option")[0].id,
|
||||||
|
);
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" });
|
||||||
|
expect(ui.getAllByRole("option")[5].getAttribute("aria-selected")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Escape" });
|
||||||
|
expect(ui.queryByRole("listbox")).toBeNull();
|
||||||
|
expect(cm.state.doc.toString()).toBe(":");
|
||||||
|
type(cm, ":comp");
|
||||||
|
await ui.findByRole("option");
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Tab" });
|
||||||
|
expect(cm.state.doc.toString()).toBe(":compact ");
|
||||||
|
expect(oncommand).not.toHaveBeenCalled();
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
|
||||||
|
expect(oncommand).toHaveBeenCalledOnce();
|
||||||
|
expect(onsubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("click selects a command and IME/Shift-Enter do not confirm it", async () => {
|
||||||
|
const oncommand = vi.fn();
|
||||||
|
const ui = render(ComposerInput, {
|
||||||
|
historyScope: "completion-ime",
|
||||||
|
oncommand,
|
||||||
|
});
|
||||||
|
const cm = editor(ui.container);
|
||||||
|
type(cm, ":rew");
|
||||||
|
await ui.findByRole("option");
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, {
|
||||||
|
key: "Enter",
|
||||||
|
isComposing: true,
|
||||||
|
keyCode: 229,
|
||||||
|
});
|
||||||
|
expect(oncommand).not.toHaveBeenCalled();
|
||||||
|
type(cm, ":rew");
|
||||||
|
await ui.findByRole("option");
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Enter", shiftKey: true });
|
||||||
|
expect(oncommand).not.toHaveBeenCalled();
|
||||||
|
type(cm, ":rew");
|
||||||
|
const option = await ui.findByRole("option");
|
||||||
|
await fireEvent.pointerDown(option);
|
||||||
|
await fireEvent.click(option);
|
||||||
|
expect(cm.state.doc.toString()).toBe(":rewind ");
|
||||||
|
expect(oncommand).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("file popup scrolls selection, drills into directories and Enter accepts without sending", async () => {
|
||||||
|
const onsubmit = vi.fn();
|
||||||
|
const resolveFileCompletions = vi.fn(async (prefix: string) =>
|
||||||
|
prefix === "src/" ? [{ value: "src/main.rs" }] : [
|
||||||
|
{ value: "src", is_dir: true },
|
||||||
|
...Array.from({ length: 8 }, (_, i) => ({ value: `file-${i}` })),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const ui = render(ComposerInput, {
|
||||||
|
historyScope: "completion-files",
|
||||||
|
onsubmit,
|
||||||
|
resolveFileCompletions,
|
||||||
|
});
|
||||||
|
const cm = editor(ui.container);
|
||||||
|
type(cm, "read @");
|
||||||
|
await waitFor(() => expect(ui.getAllByRole("option")).toHaveLength(6));
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowUp" });
|
||||||
|
expect(ui.getAllByRole("option")[5].textContent).toContain("file-7");
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "ArrowDown" });
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Tab" });
|
||||||
|
expect(cm.state.doc.toString()).toBe("read @src/");
|
||||||
|
await waitFor(() => expect(ui.getAllByRole("option")).toHaveLength(1));
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
|
||||||
|
expect(cm.state.doc.toString()).toBe("read @src/main.rs ");
|
||||||
|
expect(onsubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cursor changes, Escape, scope changes and disable discard late file results", async () => {
|
||||||
|
const old = deferred<Array<{ value: string }>>();
|
||||||
|
const resolveFileCompletions = vi.fn(() => old.promise);
|
||||||
|
const ui = render(ComposerInput, {
|
||||||
|
historyScope: "completion-race",
|
||||||
|
completionScope: "a",
|
||||||
|
resolveFileCompletions,
|
||||||
|
});
|
||||||
|
const cm = editor(ui.container);
|
||||||
|
type(cm, "@old");
|
||||||
|
await waitFor(() => expect(resolveFileCompletions).toHaveBeenCalledOnce());
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Escape" });
|
||||||
|
old.resolve([{ value: "old-result" }]);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(ui.queryByRole("listbox")).toBeNull();
|
||||||
|
type(cm, "@new");
|
||||||
|
await ui.findByRole("listbox");
|
||||||
|
cm.dispatch({ selection: { anchor: 0 } });
|
||||||
|
await waitFor(() => expect(ui.queryByRole("listbox")).toBeNull());
|
||||||
|
type(cm, ":comp");
|
||||||
|
await ui.findByRole("listbox");
|
||||||
|
await ui.rerender({ completionScope: "b" });
|
||||||
|
expect(ui.queryByRole("listbox")).toBeNull();
|
||||||
|
type(cm, ":rew");
|
||||||
|
await ui.findByRole("listbox");
|
||||||
|
await ui.rerender({ disabled: true });
|
||||||
|
expect(ui.queryByRole("listbox")).toBeNull();
|
||||||
|
});
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
type ComposerHistoryDirection,
|
type ComposerHistoryDirection,
|
||||||
type ComposerHistoryEntry,
|
type ComposerHistoryEntry,
|
||||||
} from "$lib/workspace/console/composer-history.ts";
|
} from "$lib/workspace/console/composer-history.ts";
|
||||||
|
import { applyCompletion, completionSelection, completionTokenAt, localCommandCompletions, type ComposerCompletionEntry, type ComposerCompletionToken } from "./composer-completion";
|
||||||
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -57,6 +58,9 @@
|
|||||||
onkeydown?: (event: KeyboardEvent) => void;
|
onkeydown?: (event: KeyboardEvent) => void;
|
||||||
onsubmit?: () => void;
|
onsubmit?: () => void;
|
||||||
onpasteimages?: (files: File[]) => void;
|
onpasteimages?: (files: File[]) => void;
|
||||||
|
oncommand?: () => void;
|
||||||
|
completionScope?: string;
|
||||||
|
resolveFileCompletions?: (prefix: string, signal: AbortSignal) => Promise<ComposerCompletionEntry[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -68,6 +72,9 @@
|
|||||||
onkeydown,
|
onkeydown,
|
||||||
onsubmit,
|
onsubmit,
|
||||||
onpasteimages,
|
onpasteimages,
|
||||||
|
oncommand,
|
||||||
|
completionScope = "",
|
||||||
|
resolveFileCompletions,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let mountElement: HTMLDivElement;
|
let mountElement: HTMLDivElement;
|
||||||
@@ -78,6 +85,135 @@
|
|||||||
let nextPasteKey = 1;
|
let nextPasteKey = 1;
|
||||||
const editable = new Compartment();
|
const editable = new Compartment();
|
||||||
|
|
||||||
|
const completionId = $props.id();
|
||||||
|
let completionToken = $state<ComposerCompletionToken | null>(null);
|
||||||
|
let completionEntries = $state<ComposerCompletionEntry[]>([]);
|
||||||
|
let completionSelected = $state<number | null>(null);
|
||||||
|
let completionBusy = $state(false);
|
||||||
|
let completionMessage = $state("");
|
||||||
|
let completionAbort: AbortController | null = null;
|
||||||
|
let completionVersion = 0;
|
||||||
|
let completionContext = "";
|
||||||
|
const completionStart = $derived(Math.max(0, (completionSelected ?? 0) - 5));
|
||||||
|
const visibleCompletions = $derived(completionEntries.slice(completionStart, completionStart + 6));
|
||||||
|
|
||||||
|
function closeCompletion(): void {
|
||||||
|
completionVersion++;
|
||||||
|
completionAbort?.abort();
|
||||||
|
completionAbort = null;
|
||||||
|
completionToken = null;
|
||||||
|
completionEntries = [];
|
||||||
|
completionSelected = null;
|
||||||
|
completionBusy = false;
|
||||||
|
completionMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCompletion(force = false): void {
|
||||||
|
if (!view) return;
|
||||||
|
const selection = view.state.selection.main;
|
||||||
|
const document = view.state.doc.toString();
|
||||||
|
const context = JSON.stringify([document, selection.from, selection.to]);
|
||||||
|
if (!force && context === completionContext) return;
|
||||||
|
completionContext = context;
|
||||||
|
closeCompletion();
|
||||||
|
if (disabled || !view.hasFocus || view.composing || !selection.empty) return;
|
||||||
|
const token = completionTokenAt(document, selection.head);
|
||||||
|
if (!token) return;
|
||||||
|
completionToken = token;
|
||||||
|
if (token.kind === "command") {
|
||||||
|
completionEntries = localCommandCompletions(token.prefix);
|
||||||
|
if (!completionEntries.length) completionMessage = "No matching commands";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!resolveFileCompletions) { closeCompletion(); return; }
|
||||||
|
completionBusy = true;
|
||||||
|
const version = completionVersion;
|
||||||
|
const abort = new AbortController();
|
||||||
|
completionAbort = abort;
|
||||||
|
void resolveFileCompletions(token.prefix, abort.signal).then((entries) => {
|
||||||
|
if (version !== completionVersion || abort.signal.aborted) return;
|
||||||
|
completionEntries = entries;
|
||||||
|
completionSelected = entries.length ? 0 : null;
|
||||||
|
if (!entries.length) completionMessage = "No matching files";
|
||||||
|
}).catch((error) => {
|
||||||
|
if (version !== completionVersion || abort.signal.aborted) return;
|
||||||
|
completionMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}).finally(() => {
|
||||||
|
if (version === completionVersion) completionBusy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptCompletion(action: "tab" | "accept", index = completionSelected): void {
|
||||||
|
if (!view || !completionToken || disabled) return;
|
||||||
|
const token = completionToken;
|
||||||
|
const document = view.state.doc.toString();
|
||||||
|
if (token.kind === "command" && index === null) {
|
||||||
|
const exact = completionEntries.findIndex((entry) => entry.value === token.prefix);
|
||||||
|
if (exact >= 0) index = exact;
|
||||||
|
}
|
||||||
|
if (index === null && completionEntries.length === 1) index = 0;
|
||||||
|
if (index === null && completionEntries.length > 1) {
|
||||||
|
completionMessage = "Select a command with ↑/↓ or keep typing";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = index === null ? undefined : completionEntries[index];
|
||||||
|
if (!entry) return;
|
||||||
|
const result = applyCompletion(document, token, entry, action);
|
||||||
|
const to = document.length - (result.value.length - result.cursor);
|
||||||
|
closeCompletion();
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: token.start, to, insert: result.value.slice(token.start, result.cursor) },
|
||||||
|
selection: EditorSelection.cursor(result.cursor), userEvent: "input.complete",
|
||||||
|
});
|
||||||
|
view.focus();
|
||||||
|
if (token.kind === "command" && action === "accept") oncommand?.();
|
||||||
|
else queueMicrotask(() => refreshCompletion(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionKeydown(event: KeyboardEvent): boolean {
|
||||||
|
if (disabled || event.isComposing || view?.composing || event.keyCode === 229) return false;
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false;
|
||||||
|
if (event.key === "Escape" && completionToken) {
|
||||||
|
event.preventDefault(); closeCompletion(); return true;
|
||||||
|
}
|
||||||
|
if (completionToken && completionEntries.length && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
|
||||||
|
event.preventDefault();
|
||||||
|
completionSelected = completionSelection(completionSelected, completionEntries.length, event.key === "ArrowDown" ? 1 : -1);
|
||||||
|
completionMessage = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
if (!completionToken) refreshCompletion(true);
|
||||||
|
if (!completionToken) return false;
|
||||||
|
event.preventDefault(); acceptCompletion("tab"); return true;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && completionToken && completionEntries.length) {
|
||||||
|
event.preventDefault(); acceptCompletion("accept"); return true;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && view?.state.doc.toString().trimStart().startsWith(":")) {
|
||||||
|
event.preventDefault(); oncommand?.(); return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeId = completionSelected === null ? undefined : completionId + "-" + completionSelected;
|
||||||
|
const open = completionToken !== null;
|
||||||
|
if (!view) return;
|
||||||
|
view.contentDOM.setAttribute("aria-autocomplete", "list");
|
||||||
|
if (open) view.contentDOM.setAttribute("aria-controls", completionId);
|
||||||
|
else view.contentDOM.removeAttribute("aria-controls");
|
||||||
|
if (open && activeId) view.contentDOM.setAttribute("aria-activedescendant", activeId);
|
||||||
|
else view.contentDOM.removeAttribute("aria-activedescendant");
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void completionScope;
|
||||||
|
void disabled;
|
||||||
|
closeCompletion();
|
||||||
|
completionContext = "";
|
||||||
|
});
|
||||||
|
|
||||||
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
|
const registerPaste = StateEffect.define<{ key: number; paste: ComposerPaste }>();
|
||||||
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
|
const pasteRegistry = StateField.define<ReadonlyMap<number, ComposerPaste>>({
|
||||||
create: () => new Map(),
|
create: () => new Map(),
|
||||||
@@ -333,6 +469,7 @@
|
|||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
extensions: [
|
extensions: [
|
||||||
history(),
|
history(),
|
||||||
|
Prec.highest(EditorView.domEventHandlers({ keydown: completionKeydown })),
|
||||||
Prec.highest(keymap.of([
|
Prec.highest(keymap.of([
|
||||||
{
|
{
|
||||||
key: "Mod-z",
|
key: "Mod-z",
|
||||||
@@ -381,12 +518,16 @@
|
|||||||
spellcheck: "true",
|
spellcheck: "true",
|
||||||
}),
|
}),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged || update.selectionSet) queueMicrotask(() => refreshCompletion());
|
||||||
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
|
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
|
||||||
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
|
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
|
||||||
emitChange();
|
emitChange();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
Prec.high(EditorView.domEventHandlers({
|
Prec.high(EditorView.domEventHandlers({
|
||||||
|
focus() { queueMicrotask(() => refreshCompletion(true)); return false; },
|
||||||
|
blur() { closeCompletion(); return false; },
|
||||||
|
compositionend() { queueMicrotask(() => refreshCompletion(true)); return false; },
|
||||||
paste(event) {
|
paste(event) {
|
||||||
return handlePasteEvent(event);
|
return handlePasteEvent(event);
|
||||||
},
|
},
|
||||||
@@ -440,6 +581,7 @@
|
|||||||
emitChange();
|
emitChange();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
closeCompletion();
|
||||||
view?.destroy();
|
view?.destroy();
|
||||||
view = null;
|
view = null;
|
||||||
};
|
};
|
||||||
@@ -546,16 +688,83 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="composer-input" class:disabled bind:this={mountElement}></div>
|
<div class="composer-input" class:disabled>
|
||||||
|
{#if completionToken}
|
||||||
|
<div class="completion-popup">
|
||||||
|
<div id={completionId} role="listbox" aria-label="Composer completions" aria-busy={completionBusy}>
|
||||||
|
{#each visibleCompletions as entry, offset (entry.value)}
|
||||||
|
{@const index = completionStart + offset}
|
||||||
|
<button
|
||||||
|
id={completionId + "-" + index}
|
||||||
|
type="button" role="option" tabindex="-1"
|
||||||
|
aria-selected={completionSelected === index}
|
||||||
|
title={entry.usage ?? entry.value}
|
||||||
|
onpointerdown={(event) => event.preventDefault()}
|
||||||
|
onclick={() => acceptCompletion("accept", index)}
|
||||||
|
>
|
||||||
|
<span class="completion-value">{completionToken.sigil}{entry.value}{entry.is_dir && !entry.value.endsWith("/") ? "/" : ""}</span>
|
||||||
|
{#if entry.description}<span class="completion-description">{entry.description}</span>{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="completion-hint" role="status">
|
||||||
|
{#if completionBusy}Loading files…{:else if completionMessage}{completionMessage}{:else}↑↓ select · Tab complete · Enter confirm · Esc close{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div bind:this={mountElement}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.composer-input {
|
.composer-input {
|
||||||
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.completion-popup {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: var(--space-1);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
box-shadow: var(--shadow-overlay);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: var(--font-size-compact);
|
||||||
|
line-height: var(--line-height-compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-popup button {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-popup button:hover { background: var(--interactive-hover); }
|
||||||
|
.completion-popup button[aria-selected="true"] { background: var(--interactive-selected); }
|
||||||
|
.completion-value { flex: 0 1 auto; font-family: var(--font-mono); }
|
||||||
|
.completion-description { flex: 1 1 auto; color: var(--text-muted); }
|
||||||
|
.completion-value, .completion-description {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.completion-hint { padding: var(--space-1) var(--space-2); color: var(--text-muted); }
|
||||||
|
|
||||||
.composer-input.disabled {
|
.composer-input.disabled {
|
||||||
opacity: 0.56;
|
opacity: 0.56;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render } from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import ConsoleTasks from "./ConsoleTasks.svelte";
|
||||||
|
import type { ConsoleTask } from "./tasks";
|
||||||
|
|
||||||
|
const tasks: ConsoleTask[] = [{ taskid: 1, subject: "Current task", description: "Task detail", status: "inprogress" }];
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
test("mini summary opens the pane independently of Worker view tabs", async () => {
|
||||||
|
const onTogglePane = vi.fn();
|
||||||
|
const onSelectWorkerView = vi.fn();
|
||||||
|
const view = render(ConsoleTasks, {
|
||||||
|
tasks, mode: "mini", paneId: "task-pane", paneOpen: false, onTogglePane,
|
||||||
|
workerViews: [{ sessionId: null, label: "Main" }, { sessionId: "child", label: "Child" }],
|
||||||
|
onSelectWorkerView,
|
||||||
|
});
|
||||||
|
const summary = view.getByRole("button", { name: /1 task — pending/ });
|
||||||
|
expect(summary.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(summary.getAttribute("aria-controls")).toBe("task-pane");
|
||||||
|
expect(summary.getAttribute("type")).toBe("button");
|
||||||
|
await fireEvent.click(view.getByText("Current task"));
|
||||||
|
expect(onTogglePane).not.toHaveBeenCalled();
|
||||||
|
await fireEvent.click(summary);
|
||||||
|
expect(onTogglePane).toHaveBeenCalledOnce();
|
||||||
|
await view.rerender({ paneOpen: true });
|
||||||
|
expect(summary.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
await fireEvent.click(view.getByRole("button", { name: "Child" }));
|
||||||
|
expect(onSelectWorkerView).toHaveBeenCalledWith("child");
|
||||||
|
expect(onTogglePane).toHaveBeenCalledOnce();
|
||||||
|
expect(summary.querySelector("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an open empty pane retains its mini summary close control", async () => {
|
||||||
|
const onTogglePane = vi.fn();
|
||||||
|
const view = render(ConsoleTasks, { tasks: [], mode: "mini", paneOpen: true, onTogglePane });
|
||||||
|
await fireEvent.click(view.getByRole("button", { name: /0 tasks/ }));
|
||||||
|
expect(onTogglePane).toHaveBeenCalledOnce();
|
||||||
|
await view.rerender({ paneOpen: false });
|
||||||
|
expect(view.queryByRole("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pane exposes the summary control target and full task details", () => {
|
||||||
|
const view = render(ConsoleTasks, { tasks, mode: "pane", paneId: "task-pane" });
|
||||||
|
expect(view.getByRole("complementary", { name: "Worker tasks" }).id).toBe("task-pane");
|
||||||
|
expect(view.getByText("Task detail")).not.toBeNull();
|
||||||
|
});
|
||||||
@@ -9,6 +9,9 @@
|
|||||||
type Props = {
|
type Props = {
|
||||||
tasks: ConsoleTask[];
|
tasks: ConsoleTask[];
|
||||||
mode: "mini" | "pane";
|
mode: "mini" | "pane";
|
||||||
|
paneOpen?: boolean;
|
||||||
|
paneId?: string;
|
||||||
|
onTogglePane?: () => void;
|
||||||
workerViews?: WorkerViewTab[];
|
workerViews?: WorkerViewTab[];
|
||||||
selectedWorkerViewSessionId?: string | null;
|
selectedWorkerViewSessionId?: string | null;
|
||||||
onSelectWorkerView?: (sessionId: string | null) => void;
|
onSelectWorkerView?: (sessionId: string | null) => void;
|
||||||
@@ -17,6 +20,9 @@
|
|||||||
let {
|
let {
|
||||||
tasks,
|
tasks,
|
||||||
mode,
|
mode,
|
||||||
|
paneOpen = false,
|
||||||
|
paneId,
|
||||||
|
onTogglePane,
|
||||||
workerViews = [],
|
workerViews = [],
|
||||||
selectedWorkerViewSessionId = null,
|
selectedWorkerViewSessionId = null,
|
||||||
onSelectWorkerView = () => {},
|
onSelectWorkerView = () => {},
|
||||||
@@ -46,7 +52,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if mode === "mini" && (tasks.length > 0 || workerViews.length > 1)}
|
{#if mode === "mini" && (tasks.length > 0 || workerViews.length > 1 || paneOpen)}
|
||||||
<section class="task-mini" aria-label="Worker task summary">
|
<section class="task-mini" aria-label="Worker task summary">
|
||||||
{#each activeTasks as task (task.taskid)}
|
{#each activeTasks as task (task.taskid)}
|
||||||
<div class="task-mini-row">
|
<div class="task-mini-row">
|
||||||
@@ -57,9 +63,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="task-summary-row">
|
<div class="task-summary-row">
|
||||||
<span class="task-summary">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="task-summary"
|
||||||
|
aria-expanded={paneOpen}
|
||||||
|
aria-controls={paneId}
|
||||||
|
onclick={onTogglePane}
|
||||||
|
>
|
||||||
{counts.total} {taskNoun(counts.total)} — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}
|
{counts.total} {taskNoun(counts.total)} — pending: {counts.pending}, inprogress: {counts.inprogress}, completed: {counts.completed}
|
||||||
</span>
|
</button>
|
||||||
{#if workerViews.length > 1}
|
{#if workerViews.length > 1}
|
||||||
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
|
<span class="worker-view-tabs" role="group" aria-label="Worker transcript view">
|
||||||
<span aria-hidden="true">[ </span>
|
<span aria-hidden="true">[ </span>
|
||||||
@@ -78,7 +90,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{:else if mode === "pane"}
|
{:else if mode === "pane"}
|
||||||
<aside class="task-pane" aria-label="Worker tasks">
|
<aside id={paneId} class="task-pane" aria-label="Worker tasks">
|
||||||
<h3>Tasks ({counts.total})</h3>
|
<h3>Tasks ({counts.total})</h3>
|
||||||
|
|
||||||
{#if tasks.length === 0}
|
{#if tasks.length === 0}
|
||||||
@@ -141,6 +153,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-summary {
|
.task-summary {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -148,6 +166,16 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-summary:hover,
|
||||||
|
.task-summary:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-summary:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.worker-view-tabs {
|
.worker-view-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
@@ -266,9 +294,10 @@
|
|||||||
line-height: var(--line-height-body);
|
line-height: var(--line-height-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 960px) {
|
||||||
.task-pane {
|
.task-pane {
|
||||||
display: none;
|
border-left: 0;
|
||||||
|
padding-top: var(--space-3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ function assertEquals<T>(actual: T, expected: T): void {
|
|||||||
if (a !== e) throw new Error(`Expected ${e}, got ${a}`);
|
if (a !== e) throw new Error(`Expected ${e}, got ${a}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Deno.test("compact and rewind remain explicit Composer commands", () => {
|
||||||
|
assertEquals(buildComposerSegmentsRequest([{ kind: "text", content: ":compact" }]), {
|
||||||
|
ok: true, request: { kind: "compact", content: "" }, notice: "compact requested",
|
||||||
|
});
|
||||||
|
assertEquals(buildComposerRequest(":rewind"), {
|
||||||
|
ok: true, request: { kind: "list_rewind_targets", content: "" }, notice: "rewind targets requested",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("parseSigilSegments turns file sigils into file refs", () => {
|
Deno.test("parseSigilSegments turns file sigils into file refs", () => {
|
||||||
assertEquals(parseSigilSegments("read @src/main.rs"), [
|
assertEquals(parseSigilSegments("read @src/main.rs"), [
|
||||||
{ kind: "text", content: "read " },
|
{ kind: "text", content: "read " },
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type CommandSpec = {
|
|||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMMANDS: Record<string, CommandSpec> = {
|
export const COMMANDS: Record<string, CommandSpec> = {
|
||||||
help: {
|
help: {
|
||||||
usage: ":help [command]",
|
usage: ":help [command]",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
applyCompletion,
|
applyCompletion,
|
||||||
|
completionSelection,
|
||||||
completionTokenAt,
|
completionTokenAt,
|
||||||
localCommandCompletions,
|
localCommandCompletions,
|
||||||
} from "./composer-completion.ts";
|
} from "./composer-completion.ts";
|
||||||
@@ -49,6 +50,43 @@ Deno.test("applyCompletion replaces the active token and advances the cursor", (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("completion scope, aliases and middle-of-token replacement match command semantics", () => {
|
||||||
|
assertEquals(completionTokenAt("explain :comp", 13), null);
|
||||||
|
assertEquals(completionTokenAt(":notify @file", 13), null);
|
||||||
|
assertEquals(completionTokenAt("@\uFFF91\uFFFB", 4), null);
|
||||||
|
assertEquals(localCommandCompletions("roll").map((entry) => entry.value), [
|
||||||
|
"rewind",
|
||||||
|
]);
|
||||||
|
assertEquals(localCommandCompletions("?").map((entry) => entry.value), [
|
||||||
|
"help",
|
||||||
|
]);
|
||||||
|
const value = ":notify old argument";
|
||||||
|
const token = completionTokenAt(value, 4)!;
|
||||||
|
assertEquals(
|
||||||
|
applyCompletion(value, token, { value: "peer" }).value,
|
||||||
|
":peer old argument",
|
||||||
|
);
|
||||||
|
const file = "see @sr trailing";
|
||||||
|
assertEquals(
|
||||||
|
applyCompletion(file, completionTokenAt(file, 7)!, {
|
||||||
|
value: "src/",
|
||||||
|
is_dir: true,
|
||||||
|
}).value,
|
||||||
|
"see @src/ trailing",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
applyCompletion(
|
||||||
|
"@ma",
|
||||||
|
completionTokenAt("@ma", 3)!,
|
||||||
|
{ value: "main.rs" },
|
||||||
|
"tab",
|
||||||
|
).value,
|
||||||
|
"@main.rs",
|
||||||
|
);
|
||||||
|
assertEquals(completionSelection(null, 6, -1), 5);
|
||||||
|
assertEquals(completionSelection(5, 6, 1), 0);
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("localCommandCompletions filters colon commands", () => {
|
Deno.test("localCommandCompletions filters colon commands", () => {
|
||||||
assertEquals(localCommandCompletions("com").map((entry) => entry.value), [
|
assertEquals(localCommandCompletions("com").map((entry) => entry.value), [
|
||||||
"compact",
|
"compact",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type ComposerCompletionKind = "command" | "file";
|
import { COMMANDS } from "./composer-command.ts";
|
||||||
|
|
||||||
|
export type ComposerCompletionKind = "command" | "file";
|
||||||
export type ComposerCompletionToken = {
|
export type ComposerCompletionToken = {
|
||||||
sigil: ":" | "@";
|
sigil: ":" | "@";
|
||||||
kind: ComposerCompletionKind;
|
kind: ComposerCompletionKind;
|
||||||
@@ -7,27 +8,22 @@ export type ComposerCompletionToken = {
|
|||||||
end: number;
|
end: number;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ComposerCompletionEntry = {
|
export type ComposerCompletionEntry = {
|
||||||
value: string;
|
value: string;
|
||||||
is_dir?: boolean;
|
is_dir?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
usage?: string;
|
||||||
};
|
};
|
||||||
|
export type CompletionApplyResult = { value: string; cursor: number };
|
||||||
export type CompletionApplyResult = {
|
const ALIASES: Record<string, string> = { "?": "help", rollback: "rewind" };
|
||||||
value: string;
|
export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = Object
|
||||||
cursor: number;
|
.entries(COMMANDS)
|
||||||
};
|
.filter(([name]) => !ALIASES[name])
|
||||||
|
.map(([value, spec]) => ({
|
||||||
export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = [
|
value,
|
||||||
{ value: "help", description: "Show commands" },
|
description: spec.description,
|
||||||
{ value: "noop", description: "No-op" },
|
usage: spec.usage,
|
||||||
{ value: "compact", description: "Compact Worker context" },
|
}));
|
||||||
{ value: "rewind", description: "List rewind targets" },
|
|
||||||
{ value: "rollback", description: "Alias for rewind" },
|
|
||||||
{ value: "peer", description: "Register metadata peer" },
|
|
||||||
{ value: "notify", description: "Send Worker notification" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function completionTokenAt(
|
export function completionTokenAt(
|
||||||
value: string,
|
value: string,
|
||||||
@@ -35,18 +31,22 @@ export function completionTokenAt(
|
|||||||
): ComposerCompletionToken | null {
|
): ComposerCompletionToken | null {
|
||||||
const boundedCursor = Math.max(0, Math.min(cursor, value.length));
|
const boundedCursor = Math.max(0, Math.min(cursor, value.length));
|
||||||
const before = value.slice(0, boundedCursor);
|
const before = value.slice(0, boundedCursor);
|
||||||
const match = /(^|\s)([:@])([^\s]*)$/.exec(before);
|
const match = /(^|\s)([:@])([^\s\uFFF9\uFFFB]*)$/.exec(before);
|
||||||
if (!match) {
|
if (!match) return null;
|
||||||
return null;
|
const sigil = match[2] as ":" | "@";
|
||||||
}
|
|
||||||
const sigil = match[2] as ComposerCompletionToken["sigil"];
|
|
||||||
const prefix = match[3] ?? "";
|
const prefix = match[3] ?? "";
|
||||||
const tokenStart = before.length - prefix.length - sigil.length;
|
const start = before.length - prefix.length - 1;
|
||||||
|
// Commands are only recognized at the start of the draft; their arguments
|
||||||
|
// must not accidentally become commands or file-reference completions.
|
||||||
|
if (sigil === ":" && value.slice(0, start).trim()) return null;
|
||||||
|
if (sigil === "@" && value.trimStart().startsWith(":")) return null;
|
||||||
|
const tail = /^[^\s\uFFF9\uFFFB]*/.exec(value.slice(boundedCursor))?.[0] ??
|
||||||
|
"";
|
||||||
return {
|
return {
|
||||||
sigil,
|
sigil,
|
||||||
kind: completionKindForSigil(sigil),
|
kind: sigil === ":" ? "command" : "file",
|
||||||
start: tokenStart,
|
start,
|
||||||
end: boundedCursor,
|
end: boundedCursor + tail.length,
|
||||||
prefix,
|
prefix,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -54,36 +54,43 @@ export function completionTokenAt(
|
|||||||
export function localCommandCompletions(
|
export function localCommandCompletions(
|
||||||
prefix: string,
|
prefix: string,
|
||||||
): ComposerCompletionEntry[] {
|
): ComposerCompletionEntry[] {
|
||||||
const normalized = prefix.toLowerCase();
|
|
||||||
return COLON_COMMAND_COMPLETIONS.filter((entry) =>
|
return COLON_COMMAND_COMPLETIONS.filter((entry) =>
|
||||||
entry.value.toLowerCase().startsWith(normalized)
|
entry.value.startsWith(prefix) || Object.entries(ALIASES).some(
|
||||||
|
([alias, name]) => name === entry.value && alias.startsWith(prefix),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function completionSelection(
|
||||||
|
selected: number | null,
|
||||||
|
count: number,
|
||||||
|
direction: 1 | -1,
|
||||||
|
): number | null {
|
||||||
|
if (!count) return null;
|
||||||
|
return selected === null
|
||||||
|
? (direction === 1 ? 0 : count - 1)
|
||||||
|
: (selected + direction + count) % count;
|
||||||
|
}
|
||||||
|
|
||||||
export function applyCompletion(
|
export function applyCompletion(
|
||||||
value: string,
|
value: string,
|
||||||
token: ComposerCompletionToken,
|
token: ComposerCompletionToken,
|
||||||
entry: ComposerCompletionEntry,
|
entry: ComposerCompletionEntry,
|
||||||
|
action: "tab" | "accept" = "accept",
|
||||||
): CompletionApplyResult {
|
): CompletionApplyResult {
|
||||||
const suffix = entry.is_dir ? "/" : " ";
|
const suffix = entry.is_dir
|
||||||
|
? (entry.value.endsWith("/") ? "" : "/")
|
||||||
|
: token.kind === "file" && action === "tab"
|
||||||
|
? ""
|
||||||
|
: " ";
|
||||||
const replacement = `${token.sigil}${entry.value}${suffix}`;
|
const replacement = `${token.sigil}${entry.value}${suffix}`;
|
||||||
const restStart = !entry.is_dir && value[token.end] === " "
|
const restStart = suffix === " " && value[token.end] === " "
|
||||||
? token.end + 1
|
? token.end + 1
|
||||||
: token.end;
|
: token.end;
|
||||||
const next = `${value.slice(0, token.start)}${replacement}${
|
return {
|
||||||
|
value: `${value.slice(0, token.start)}${replacement}${
|
||||||
value.slice(restStart)
|
value.slice(restStart)
|
||||||
}`;
|
}`,
|
||||||
const cursor = token.start + replacement.length;
|
cursor: token.start + replacement.length,
|
||||||
return { value: next, cursor };
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function completionKindForSigil(
|
|
||||||
sigil: ComposerCompletionToken["sigil"],
|
|
||||||
): ComposerCompletionKind {
|
|
||||||
switch (sigil) {
|
|
||||||
case ":":
|
|
||||||
return "command";
|
|
||||||
case "@":
|
|
||||||
return "file";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { FileCompletions } from "./file-completions.ts";
|
||||||
|
import { deepStrictEqual as assertEquals, rejects } from "node:assert/strict";
|
||||||
|
|
||||||
|
async function assertRejects(
|
||||||
|
fn: () => Promise<unknown>,
|
||||||
|
_type: typeof Error,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
await rejects(
|
||||||
|
fn,
|
||||||
|
(error: unknown) =>
|
||||||
|
error instanceof Error && error.message.includes(message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||||
|
|
||||||
|
Deno.test("file completion serializes the latest prefix behind cancelled wire replies", async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const lane = new FileCompletions((prefix) => sent.push(prefix));
|
||||||
|
const first = new AbortController();
|
||||||
|
const a = lane.request("a", first.signal);
|
||||||
|
first.abort();
|
||||||
|
assertEquals(await a, []);
|
||||||
|
const second = new AbortController();
|
||||||
|
const b = lane.request("b", second.signal);
|
||||||
|
second.abort();
|
||||||
|
const c = lane.request("c", new AbortController().signal);
|
||||||
|
assertEquals(await b, []);
|
||||||
|
assertEquals(sent, ["a"]);
|
||||||
|
lane.receive([{ value: "old-a" }]);
|
||||||
|
assertEquals(sent, ["a", "c"]);
|
||||||
|
lane.receive([{ value: "current-c" }]);
|
||||||
|
assertEquals(await c, [{ value: "current-c" }]);
|
||||||
|
lane.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("file completion timeout blocks late responses until a new connection", async () => {
|
||||||
|
const sent: string[] = [];
|
||||||
|
const lane = new FileCompletions((prefix) => sent.push(prefix), 5);
|
||||||
|
await assertRejects(
|
||||||
|
() => lane.request("a", new AbortController().signal),
|
||||||
|
Error,
|
||||||
|
"timed out",
|
||||||
|
);
|
||||||
|
lane.receive([{ value: "late-a" }]);
|
||||||
|
await assertRejects(
|
||||||
|
() => lane.request("b", new AbortController().signal),
|
||||||
|
Error,
|
||||||
|
"timed out",
|
||||||
|
);
|
||||||
|
assertEquals(sent, ["a"]);
|
||||||
|
lane.reset();
|
||||||
|
const c = lane.request("c", new AbortController().signal);
|
||||||
|
lane.receive([{ value: "c" }]);
|
||||||
|
assertEquals(await c, [{ value: "c" }]);
|
||||||
|
lane.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("file completion close rejects both active and queued requests", async () => {
|
||||||
|
const lane = new FileCompletions(() => {});
|
||||||
|
const a = lane.request("a", new AbortController().signal);
|
||||||
|
const b = lane.request("b", new AbortController().signal);
|
||||||
|
const outcomes = Promise.allSettled([a, b]);
|
||||||
|
lane.close(new Error("disconnected"));
|
||||||
|
assertEquals((await outcomes).map((value) => value.status), [
|
||||||
|
"rejected",
|
||||||
|
"rejected",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { ComposerCompletionEntry } from "./composer-completion.ts";
|
||||||
|
|
||||||
|
type Request = {
|
||||||
|
prefix: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
resolve: (entries: ComposerCompletionEntry[]) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
abort: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The wire reply has no request id/prefix. Never overlap requests on a lane,
|
||||||
|
* including cancelled requests: their reply must be consumed before the next.
|
||||||
|
* A timeout poisons the lane until reconnect, rather than misrouting a late reply.
|
||||||
|
*/
|
||||||
|
export class FileCompletions {
|
||||||
|
private active: Request | null = null;
|
||||||
|
private queued: Request | null = null;
|
||||||
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
private failure: Error | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private send: (prefix: string) => void,
|
||||||
|
private timeoutMs = 30_000,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get pending(): boolean {
|
||||||
|
return this.active !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
request(
|
||||||
|
prefix: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<ComposerCompletionEntry[]> {
|
||||||
|
if (signal.aborted) return Promise.resolve([]);
|
||||||
|
if (this.failure) return Promise.reject(this.failure);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request: Request = {
|
||||||
|
prefix,
|
||||||
|
signal,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
abort: () => {
|
||||||
|
resolve([]);
|
||||||
|
if (this.queued === request) this.queued = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", request.abort, { once: true });
|
||||||
|
if (this.queued) this.finish(this.queued, []);
|
||||||
|
this.queued = request;
|
||||||
|
this.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
receive(entries: ComposerCompletionEntry[]): void {
|
||||||
|
if (!this.active) return;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
const request = this.active;
|
||||||
|
this.active = null;
|
||||||
|
this.finish(request, entries);
|
||||||
|
this.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
close(error = new Error("Worker completion connection closed.")): void {
|
||||||
|
this.failure = error;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
for (const request of [this.active, this.queued]) {
|
||||||
|
if (!request) continue;
|
||||||
|
request.signal.removeEventListener("abort", request.abort);
|
||||||
|
request.reject(error);
|
||||||
|
}
|
||||||
|
this.active = this.queued = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.close();
|
||||||
|
this.failure = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish(request: Request, entries: ComposerCompletionEntry[]): void {
|
||||||
|
request.signal.removeEventListener("abort", request.abort);
|
||||||
|
request.resolve(request.signal.aborted ? [] : entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pump(): void {
|
||||||
|
if (this.active || !this.queued || this.failure) return;
|
||||||
|
const request = this.queued;
|
||||||
|
this.queued = null;
|
||||||
|
if (request.signal.aborted) {
|
||||||
|
this.finish(request, []);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.active = request;
|
||||||
|
this.timer = setTimeout(
|
||||||
|
() =>
|
||||||
|
this.close(
|
||||||
|
new Error("Worker completion request timed out; reconnect to retry."),
|
||||||
|
),
|
||||||
|
this.timeoutMs,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
this.send(request.prefix);
|
||||||
|
} catch (error) {
|
||||||
|
this.close(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -985,7 +985,8 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
|||||||
composerInput.includes('key: "Mod-z"') &&
|
composerInput.includes('key: "Mod-z"') &&
|
||||||
composerInput.includes("if (!view || view.state.readOnly) return") &&
|
composerInput.includes("if (!view || view.state.readOnly) return") &&
|
||||||
composerInput.includes("if (currentView.state.readOnly) return false") &&
|
composerInput.includes("if (currentView.state.readOnly) return false") &&
|
||||||
consolePage.includes("activeComposerTargetKey !== targetKey") &&
|
consolePage.includes("completionScope={activeComposerTargetKey}") &&
|
||||||
|
composerInput.includes("version !== completionVersion") &&
|
||||||
consolePage.includes("if (!composerEditable) return") &&
|
consolePage.includes("if (!composerEditable) return") &&
|
||||||
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
composerInput.includes('chip.setAttribute("aria-label", label)') &&
|
||||||
composerInput.includes("preserveExactText = false") &&
|
composerInput.includes("preserveExactText = false") &&
|
||||||
@@ -1489,6 +1490,28 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header buttons", async () => {
|
||||||
|
const page = await Deno.readTextFile(new URL("./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url));
|
||||||
|
const input = await Deno.readTextFile(new URL("./ComposerInput.svelte", import.meta.url));
|
||||||
|
const header = page.slice(page.indexOf('<section class="console-header'), page.indexOf('{#if rewindTargets.length'));
|
||||||
|
assert(!header.includes("Compact") && !header.includes("Rewind") && !page.includes("requestRewindTargets"), "Header must defer Compact/Rewind to commands");
|
||||||
|
assert(input.includes('role="listbox"') && input.includes('role="option"') && input.includes("entry.description") && input.includes("completionStart + 6"), "Completion must expose a bounded selectable list with descriptions");
|
||||||
|
assert(input.indexOf("keydown: completionKeydown") < input.indexOf('key: "ArrowUp"'), "Completion navigation must precede input history");
|
||||||
|
assert(page.includes("fileCompletions.receive(event.data.entries)") && page.includes("fileCompletions.close()"), "File completion lifecycle must be scoped to the transport");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("mini task summary owns pane toggling instead of the header", async () => {
|
||||||
|
const page = await Deno.readTextFile(new URL(
|
||||||
|
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", import.meta.url,
|
||||||
|
));
|
||||||
|
const component = await Deno.readTextFile(new URL("./ConsoleTasks.svelte", import.meta.url));
|
||||||
|
const header = page.slice(page.indexOf('<section class="console-header'), page.indexOf('{#if rewindTargets.length'));
|
||||||
|
assert(!header.includes("taskPaneOpen = !taskPaneOpen") && page.includes("onTogglePane={() => {"), "Only the mini summary should own Tasks toggling");
|
||||||
|
assert(component.includes("aria-expanded={paneOpen}") && component.includes("aria-controls={paneId}") && component.includes("onclick={onTogglePane}"), "Tasks summary must be an accessible pane toggle");
|
||||||
|
assert(!/\.task-pane\s*\{[^}]*display:\s*none/.test(component), "Narrow layouts must not hide the opened Tasks pane");
|
||||||
|
assert(page.includes("grid-template-rows: minmax(0, 1fr) minmax(0, 1fr)"), "Narrow layouts should bound transcript and task scrolling separately");
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => {
|
Deno.test("Pending inputs are single-line previews between Tasks and Composer", async () => {
|
||||||
const page = await Deno.readTextFile(new URL(
|
const page = await Deno.readTextFile(new URL(
|
||||||
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
"./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte",
|
||||||
|
|||||||
+41
-182
@@ -17,12 +17,7 @@
|
|||||||
buildComposerSegmentsRequest,
|
buildComposerSegmentsRequest,
|
||||||
type WorkerConsoleInputRequest,
|
type WorkerConsoleInputRequest,
|
||||||
} from "$lib/workspace/console/composer-command";
|
} from "$lib/workspace/console/composer-command";
|
||||||
import {
|
import { FileCompletions } from "$lib/workspace/console/file-completions";
|
||||||
completionTokenAt,
|
|
||||||
localCommandCompletions,
|
|
||||||
type ComposerCompletionEntry,
|
|
||||||
type ComposerCompletionToken,
|
|
||||||
} from "$lib/workspace/console/composer-completion";
|
|
||||||
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
|
import WorkerRunStatus from "$lib/workspace/console/WorkerRunStatus.svelte";
|
||||||
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
|
import { resolveWorkerControlShortcut } from "$lib/workspace/console/worker-control-shortcuts";
|
||||||
import {
|
import {
|
||||||
@@ -141,10 +136,6 @@
|
|||||||
let nextAttachmentId = 1;
|
let nextAttachmentId = 1;
|
||||||
let fileInput: HTMLInputElement | null = null;
|
let fileInput: HTMLInputElement | null = null;
|
||||||
let isDraggingFiles = $state(false);
|
let isDraggingFiles = $state(false);
|
||||||
let completionEntries = $state<ComposerCompletionEntry[]>([]);
|
|
||||||
let completionToken = $state<ComposerCompletionToken | null>(null);
|
|
||||||
let completionBusy = $state(false);
|
|
||||||
let completionError = $state<string | null>(null);
|
|
||||||
let sending = $state(false);
|
let sending = $state(false);
|
||||||
let sendError = $state<string | null>(null);
|
let sendError = $state<string | null>(null);
|
||||||
let rewindTargets = $state<RewindTarget[]>([]);
|
let rewindTargets = $state<RewindTarget[]>([]);
|
||||||
@@ -164,11 +155,9 @@
|
|||||||
submissions: [],
|
submissions: [],
|
||||||
});
|
});
|
||||||
let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []);
|
let pendingSubmissionItems = $derived(pendingSubmissions.submissions ?? []);
|
||||||
let pendingCompletionRequest: {
|
const fileCompletions = new FileCompletions((prefix) => sendProtocolMethod({
|
||||||
resolve: (entries: ComposerCompletionEntry[]) => void;
|
method: "list_completions", params: { kind: "file", prefix },
|
||||||
reject: (error: Error) => void;
|
}));
|
||||||
timeout: number;
|
|
||||||
} | null = null;
|
|
||||||
let streamDiagnostics = $state<Diagnostic[]>([]);
|
let streamDiagnostics = $state<Diagnostic[]>([]);
|
||||||
let workerDetailsOpen = $state(false);
|
let workerDetailsOpen = $state(false);
|
||||||
let taskPaneOpen = $state(false);
|
let taskPaneOpen = $state(false);
|
||||||
@@ -181,9 +170,9 @@
|
|||||||
(SvelteComponent & ComposerInputHandle) | null
|
(SvelteComponent & ComposerInputHandle) | null
|
||||||
>(null);
|
>(null);
|
||||||
const composerDrafts = new Map<string, ComposerDraftCache>();
|
const composerDrafts = new Map<string, ComposerDraftCache>();
|
||||||
let activeComposerTargetKey = untrack(() =>
|
let activeComposerTargetKey = $state(untrack(() =>
|
||||||
runtimeId && workerId ? `${workspaceId}:${runtimeId}:${workerId}` : "",
|
runtimeId && workerId ? `${workspaceId}:${runtimeId}:${workerId}` : "",
|
||||||
);
|
));
|
||||||
let autoFollowConsole = $state(true);
|
let autoFollowConsole = $state(true);
|
||||||
const consoleViewScroll = new Map<string, ConsoleViewScroll>();
|
const consoleViewScroll = new Map<string, ConsoleViewScroll>();
|
||||||
const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
|
const CONSOLE_BOTTOM_THRESHOLD_PX = 48;
|
||||||
@@ -749,96 +738,11 @@
|
|||||||
scheduleObservationFlush();
|
scheduleObservationFlush();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyComposerCompletion() {
|
|
||||||
if (!composerEditable || !composerInputElement) return;
|
|
||||||
const input = composerInputElement;
|
|
||||||
const targetKey = activeComposerTargetKey;
|
|
||||||
const document = draft.document;
|
|
||||||
const token = completionTokenAt(
|
|
||||||
document,
|
|
||||||
input.cursor(),
|
|
||||||
);
|
|
||||||
completionToken = token;
|
|
||||||
completionError = null;
|
|
||||||
if (!token) {
|
|
||||||
completionEntries = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
completionBusy = true;
|
|
||||||
try {
|
|
||||||
const entries = await resolveCompletionEntries(token);
|
|
||||||
if (
|
|
||||||
!composerEditable ||
|
|
||||||
composerInputElement !== input ||
|
|
||||||
activeComposerTargetKey !== targetKey ||
|
|
||||||
draft.document !== document
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
completionEntries = entries;
|
|
||||||
if (entries.length === 0) {
|
|
||||||
completionError = `No completions for ${token.sigil}${token.prefix}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
input.replaceRange(
|
|
||||||
token.start,
|
|
||||||
token.end,
|
|
||||||
`${entries[0].value} `,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
completionError =
|
|
||||||
error instanceof Error ? error.message : String(error);
|
|
||||||
} finally {
|
|
||||||
completionBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveCompletionEntries(
|
|
||||||
token: ComposerCompletionToken,
|
|
||||||
): Promise<ComposerCompletionEntry[]> {
|
|
||||||
if (token.kind === "command") {
|
|
||||||
return localCommandCompletions(token.prefix);
|
|
||||||
}
|
|
||||||
const completionKind = token.kind;
|
|
||||||
const completionPrefix = token.prefix;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (pendingCompletionRequest) {
|
|
||||||
rejectPendingCompletion(
|
|
||||||
new Error("Superseded by a newer completion request."),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
rejectPendingCompletion(
|
|
||||||
new Error("Worker completion request timed out."),
|
|
||||||
);
|
|
||||||
}, 30_000);
|
|
||||||
pendingCompletionRequest = { resolve, reject, timeout };
|
|
||||||
try {
|
|
||||||
sendProtocolMethod({
|
|
||||||
method: "list_completions",
|
|
||||||
params: { kind: completionKind, prefix: completionPrefix },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
rejectPendingCompletion(
|
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleComposerKeydown(event: KeyboardEvent) {
|
function handleComposerKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "PageUp" || event.key === "PageDown") {
|
if (event.key === "PageUp" || event.key === "PageDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
scrollConsoleByPage(event.key === "PageDown" ? 1 : -1);
|
scrollConsoleByPage(event.key === "PageDown" ? 1 : -1);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (event.key !== "Tab") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
if (!composerEditable) return;
|
|
||||||
void applyComposerCompletion();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollConsoleByPage(direction: 1 | -1) {
|
function scrollConsoleByPage(direction: 1 | -1) {
|
||||||
@@ -946,10 +850,6 @@
|
|||||||
sendWorkerControl(command);
|
sendWorkerControl(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestRewindTargets() {
|
|
||||||
sendControl({ method: "list_rewind_targets" }, "Rewind target request");
|
|
||||||
}
|
|
||||||
|
|
||||||
function rewindTo(target: RewindTarget) {
|
function rewindTo(target: RewindTarget) {
|
||||||
sendControl(
|
sendControl(
|
||||||
{
|
{
|
||||||
@@ -1050,7 +950,15 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleComposerCommand() {
|
||||||
|
void submitDraft(composerInputElement?.snapshot() ?? draft);
|
||||||
|
}
|
||||||
|
|
||||||
function handleComposerSubmit() {
|
function handleComposerSubmit() {
|
||||||
|
if ((composerInputElement?.snapshot() ?? draft).document.trimStart().startsWith(":")) {
|
||||||
|
handleComposerCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (workerRunning) {
|
if (workerRunning) {
|
||||||
sendWorkerControl("cancel");
|
sendWorkerControl("cancel");
|
||||||
return;
|
return;
|
||||||
@@ -1205,6 +1113,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!command.request) {
|
if (!command.request) {
|
||||||
|
if (command.notice) pushWorkspaceAlert("info", command.notice, { title: "Console command" });
|
||||||
composerInputElement?.clear();
|
composerInputElement?.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1216,7 +1125,10 @@
|
|||||||
hasText: value.content.trim().length > 0,
|
hasText: value.content.trim().length > 0,
|
||||||
hasAttachments: attachments.length > 0,
|
hasAttachments: attachments.length > 0,
|
||||||
};
|
};
|
||||||
if (!canDeliverComposerDraft(deliveryState)) {
|
// Commands are controls, not idle-only chat submissions. Keep the
|
||||||
|
// removed header controls available while the Worker is busy too.
|
||||||
|
const isCommand = delivery === "submit" && command.request.kind !== "user";
|
||||||
|
if (isCommand ? !composerEditable : !canDeliverComposerDraft(deliveryState)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,7 +1148,9 @@
|
|||||||
sendError = null;
|
sendError = null;
|
||||||
try {
|
try {
|
||||||
const method = composerRequestToProtocolMethod(request);
|
const method = composerRequestToProtocolMethod(request);
|
||||||
if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) {
|
if (isCommand) {
|
||||||
|
sendProtocolMethod(method);
|
||||||
|
} else if (!sendComposerDelivery(deliveryState, method, sendProtocolMethod)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
composerInputElement?.recordHistory(value);
|
composerInputElement?.recordHistory(value);
|
||||||
@@ -1428,6 +1342,7 @@
|
|||||||
"The live subscription did not provide an initial snapshot.",
|
"The live subscription did not provide an initial snapshot.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
fileCompletions.reset();
|
||||||
protocolState = "open";
|
protocolState = "open";
|
||||||
} else if (
|
} else if (
|
||||||
frame.frame === "event" &&
|
frame.frame === "event" &&
|
||||||
@@ -1480,7 +1395,10 @@
|
|||||||
);
|
);
|
||||||
protocolSubscription = subscription;
|
protocolSubscription = subscription;
|
||||||
return () => {
|
return () => {
|
||||||
if (protocolSubscription === subscription) protocolSubscription = null;
|
if (protocolSubscription === subscription) {
|
||||||
|
protocolSubscription = null;
|
||||||
|
fileCompletions.close();
|
||||||
|
}
|
||||||
subscription.close();
|
subscription.close();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1494,13 +1412,7 @@
|
|||||||
|
|
||||||
function handleProtocolCommandEvent(event: ProtocolEvent) {
|
function handleProtocolCommandEvent(event: ProtocolEvent) {
|
||||||
if (event.event === "completions") {
|
if (event.event === "completions") {
|
||||||
const pending = pendingCompletionRequest;
|
if (event.data.kind === "file") fileCompletions.receive(event.data.entries);
|
||||||
if (!pending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingCompletionRequest = null;
|
|
||||||
window.clearTimeout(pending.timeout);
|
|
||||||
pending.resolve(event.data.entries);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.event === "rewind_targets") {
|
if (event.event === "rewind_targets") {
|
||||||
@@ -1517,7 +1429,7 @@
|
|||||||
}
|
}
|
||||||
if (event.event === "error") {
|
if (event.event === "error") {
|
||||||
const error = new Error(event.data.message);
|
const error = new Error(event.data.message);
|
||||||
if (pendingCompletionRequest) {
|
if (fileCompletions.pending) {
|
||||||
rejectPendingCompletion(error);
|
rejectPendingCompletion(error);
|
||||||
}
|
}
|
||||||
streamDiagnostics = [
|
streamDiagnostics = [
|
||||||
@@ -1532,13 +1444,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rejectPendingCompletion(error: Error) {
|
function rejectPendingCompletion(error: Error) {
|
||||||
const pending = pendingCompletionRequest;
|
fileCompletions.close(error);
|
||||||
if (!pending) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingCompletionRequest = null;
|
|
||||||
window.clearTimeout(pending.timeout);
|
|
||||||
pending.reject(error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
|
function mergeDiagnostics(...groups: Diagnostic[][]): Diagnostic[] {
|
||||||
@@ -1756,36 +1662,6 @@
|
|||||||
Normal
|
Normal
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="secondary-button"
|
|
||||||
disabled={protocolState !== "open"}
|
|
||||||
onclick={() => {
|
|
||||||
const method = lifecycleMethod("compact");
|
|
||||||
if (method) sendControl(method, "Compact");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Compact
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="secondary-button"
|
|
||||||
disabled={protocolState !== "open"}
|
|
||||||
onclick={requestRewindTargets}
|
|
||||||
>
|
|
||||||
Rewind
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="secondary-button"
|
|
||||||
aria-expanded={taskPaneOpen}
|
|
||||||
onclick={() => {
|
|
||||||
taskPaneOpen = !taskPaneOpen;
|
|
||||||
if (taskPaneOpen) workerDetailsOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tasks{tasks.length > 0 ? ` ${tasks.length}` : ""}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary-button"
|
class="secondary-button"
|
||||||
@@ -1888,7 +1764,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if taskPaneOpen}
|
{#if taskPaneOpen}
|
||||||
<ConsoleTasks {tasks} mode="pane" />
|
<ConsoleTasks {tasks} mode="pane" paneId="console-task-pane" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1973,6 +1849,12 @@
|
|||||||
<ConsoleTasks
|
<ConsoleTasks
|
||||||
{tasks}
|
{tasks}
|
||||||
mode="mini"
|
mode="mini"
|
||||||
|
paneId="console-task-pane"
|
||||||
|
paneOpen={taskPaneOpen}
|
||||||
|
onTogglePane={() => {
|
||||||
|
taskPaneOpen = !taskPaneOpen;
|
||||||
|
if (taskPaneOpen) workerDetailsOpen = false;
|
||||||
|
}}
|
||||||
workerViews={workerViews.map(({ sessionId, label }) => ({
|
workerViews={workerViews.map(({ sessionId, label }) => ({
|
||||||
sessionId,
|
sessionId,
|
||||||
label,
|
label,
|
||||||
@@ -2064,6 +1946,9 @@
|
|||||||
disabled={!composerEditable}
|
disabled={!composerEditable}
|
||||||
onchange={handleComposerChange}
|
onchange={handleComposerChange}
|
||||||
onkeydown={handleComposerKeydown}
|
onkeydown={handleComposerKeydown}
|
||||||
|
completionScope={activeComposerTargetKey}
|
||||||
|
resolveFileCompletions={(prefix, signal) => fileCompletions.request(prefix, signal)}
|
||||||
|
oncommand={handleComposerCommand}
|
||||||
onsubmit={handleComposerSubmit}
|
onsubmit={handleComposerSubmit}
|
||||||
onpasteimages={addPastedImages}
|
onpasteimages={addPastedImages}
|
||||||
/>
|
/>
|
||||||
@@ -2108,23 +1993,6 @@
|
|||||||
<path d="M12 5V19M5 12H19" />
|
<path d="M12 5V19M5 12H19" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if completionBusy || completionError || completionEntries.length > 0}
|
|
||||||
<div class="composer-completions" aria-live="polite">
|
|
||||||
{#if completionBusy}
|
|
||||||
<span>completing…</span>
|
|
||||||
{:else if completionError}
|
|
||||||
<span class="error">{completionError}</span>
|
|
||||||
{:else}
|
|
||||||
<span
|
|
||||||
>Tab: {completionToken?.sigil}{completionEntries[0]
|
|
||||||
?.value}</span
|
|
||||||
>
|
|
||||||
{#if completionEntries.length > 1}
|
|
||||||
<span>{completionEntries.length - 1} more</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="composer-submit-actions">
|
<div class="composer-submit-actions">
|
||||||
{#if workerRunning || workerPaused}
|
{#if workerRunning || workerPaused}
|
||||||
@@ -2703,16 +2571,6 @@
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-completions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-items: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: var(--font-size-compact);
|
|
||||||
line-height: var(--line-height-compact);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-submit-actions {
|
.composer-submit-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2722,6 +2580,7 @@
|
|||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
.console-history.with-task-pane {
|
.console-history.with-task-pane {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.console-header {
|
.console-header {
|
||||||
|
|||||||
+90
@@ -15,6 +15,7 @@ import type {
|
|||||||
SessionSnapshot,
|
SessionSnapshot,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
import type { Worker } from "$lib/workspace/sidebar/types";
|
import type { Worker } from "$lib/workspace/sidebar/types";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
import ConsolePage from "./+page.svelte";
|
import ConsolePage from "./+page.svelte";
|
||||||
|
|
||||||
const multiplexer = vi.hoisted(() => {
|
const multiplexer = vi.hoisted(() => {
|
||||||
@@ -318,6 +319,95 @@ test("queue-only state keeps the notification column empty and disables cancella
|
|||||||
await waitFor(() => expect((screen.getByRole("button", { name: "Cancel queued input 1" }) as HTMLButtonElement).disabled).toBe(true));
|
await waitFor(() => expect((screen.getByRole("button", { name: "Cancel queued input 1" }) as HTMLButtonElement).disabled).toBe(true));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(["idle", "running", "paused"] as const)("Composer completion executes Compact/Rewind without header controls or Submit (%s)", async (state) => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
|
||||||
|
const view = render(ConsolePage, { data: pageData() });
|
||||||
|
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||||
|
const frame = subscribedFrame(emptySession());
|
||||||
|
const snapshot = frame.message.payload.snapshot.data.events[0];
|
||||||
|
if (snapshot.event === "snapshot" && state !== "idle") {
|
||||||
|
snapshot.data.state.state = { kind: "busy", state: { kind: "run", state } };
|
||||||
|
}
|
||||||
|
latestListener().onFrame(frame);
|
||||||
|
await screen.findByText("No conversation to display");
|
||||||
|
expect(screen.queryByRole("button", { name: "Compact" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Rewind" })).toBeNull();
|
||||||
|
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
|
||||||
|
cm.focus();
|
||||||
|
cm.dispatch({ changes: { from: 0, insert: ":comp" }, selection: { anchor: 5 } });
|
||||||
|
await screen.findByRole("option");
|
||||||
|
await fireEvent.keyDown(cm.contentDOM, { key: "Enter" });
|
||||||
|
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({
|
||||||
|
method: "compact", params: { command: expect.objectContaining({ command_id: 1 }) },
|
||||||
|
}));
|
||||||
|
await waitFor(() => expect(cm.state.doc.toString()).toBe(""));
|
||||||
|
cm.dispatch({ changes: { from: 0, insert: ":rew" }, selection: { anchor: 4 } });
|
||||||
|
const option = await screen.findByRole("option");
|
||||||
|
await fireEvent.pointerDown(option);
|
||||||
|
await fireEvent.click(option);
|
||||||
|
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_rewind_targets" }));
|
||||||
|
expect(multiplexer.sendWorkerMethod.mock.calls.every(([method]) => method.method !== "submit" && method.method !== "cancel")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("file completions consume stale responses before the latest prefix and close with the connection", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
|
||||||
|
const view = render(ConsolePage, { data: pageData() });
|
||||||
|
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||||
|
latestListener().onFrame(subscribedFrame(emptySession()));
|
||||||
|
await screen.findByText("No conversation to display");
|
||||||
|
const cm = EditorView.findFromDOM(view.container.querySelector(".cm-editor") as HTMLElement)!;
|
||||||
|
cm.focus();
|
||||||
|
cm.dispatch({ changes: { from: 0, insert: "@old" }, selection: { anchor: 4 } });
|
||||||
|
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "old" } }));
|
||||||
|
cm.dispatch({ changes: { from: 1, to: 4, insert: "new" }, selection: { anchor: 4 } });
|
||||||
|
await settleMicrotasks();
|
||||||
|
expect(multiplexer.sendWorkerMethod).toHaveBeenCalledOnce();
|
||||||
|
const receive = (value: string) => latestListener().onFrame({
|
||||||
|
frame: "event", message: { event: "event", data: { payload: {
|
||||||
|
event: "worker_protocol", data: { event: { event: "completions", data: { kind: "file", entries: [{ value, is_dir: false }] } } },
|
||||||
|
} } },
|
||||||
|
});
|
||||||
|
receive("old-result");
|
||||||
|
await waitFor(() => expect(multiplexer.sendWorkerMethod).toHaveBeenCalledWith({ method: "list_completions", params: { kind: "file", prefix: "new" } }));
|
||||||
|
expect(screen.queryByRole("option")).toBeNull();
|
||||||
|
receive("new-result");
|
||||||
|
expect((await screen.findByRole("option")).textContent).toContain("@new-result");
|
||||||
|
latestListener().onStatus?.("closed", "connection lost");
|
||||||
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||||
|
receive("late-result");
|
||||||
|
await settleMicrotasks();
|
||||||
|
expect(screen.queryByRole("listbox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mini task summary opens and closes details without a header Tasks button", async () => {
|
||||||
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
|
||||||
|
const view = render(ConsolePage, { data: pageData() });
|
||||||
|
await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce());
|
||||||
|
const session = emptySession();
|
||||||
|
session.entries.push({
|
||||||
|
kind: "tool_call", entry_id: "task-create", timestamp: 1, provenance: "model_output", derived_from: [],
|
||||||
|
call_id: "task-call", name: "TaskCreate",
|
||||||
|
arguments: JSON.stringify({ subject: "Inspect layout", description: "Detailed task description" }),
|
||||||
|
});
|
||||||
|
latestListener().onFrame(subscribedFrame(session));
|
||||||
|
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
|
||||||
|
expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull();
|
||||||
|
expect(view.container.querySelector(".console-header")?.textContent).not.toContain("Tasks");
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Details" }));
|
||||||
|
const details = await screen.findByRole("complementary", { name: "Worker detail" });
|
||||||
|
expect(details.textContent).not.toContain("Capabilities");
|
||||||
|
expect(details.textContent).not.toContain("follow-up spawn");
|
||||||
|
await fireEvent.click(summary);
|
||||||
|
const pane = await screen.findByRole("complementary", { name: "Worker tasks" });
|
||||||
|
expect(summary.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
expect(summary.getAttribute("aria-controls")).toBe(pane.id);
|
||||||
|
expect(pane.textContent).toContain("Detailed task description");
|
||||||
|
expect(screen.queryByRole("complementary", { name: "Worker detail" })).toBeNull();
|
||||||
|
await fireEvent.click(summary);
|
||||||
|
await waitFor(() => expect(screen.queryByRole("complementary", { name: "Worker tasks" })).toBeNull());
|
||||||
|
expect(summary.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
test("turn navigation jumps only the transcript to its user message and stops bottom-follow", async () => {
|
test("turn navigation jumps only the transcript to its user message and stops bottom-follow", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ availability: "live_protocol" })));
|
||||||
const view = render(ConsolePage, { data: pageData() });
|
const view = render(ConsolePage, { data: pageData() });
|
||||||
|
|||||||
Reference in New Issue
Block a user