feat: add web console commands
This commit is contained in:
@@ -1383,6 +1383,15 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.composer-completions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.console-header {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -18,20 +18,30 @@ function assertEquals<T>(actual: T, expected: T): void {
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("shouldSubmitChatKey defaults to Cmd+Enter submit behavior", () => {
|
||||
Deno.test("shouldSubmitChatKey supports platform-auto submit modifier", () => {
|
||||
assert(
|
||||
shouldSubmitChatKey(
|
||||
{ key: "Enter", ctrlKey: true },
|
||||
{ mode: "mod-enter", modKey: "auto", enabled: true },
|
||||
),
|
||||
"Ctrl+Enter should submit on non-Apple platforms",
|
||||
);
|
||||
assertEquals(
|
||||
shouldSubmitChatKey(
|
||||
{ key: "Enter" },
|
||||
{ mode: "mod-enter", modKey: "auto", enabled: true },
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("shouldSubmitChatKey still supports explicit Cmd+Enter behavior", () => {
|
||||
assert(
|
||||
shouldSubmitChatKey(
|
||||
{ key: "Enter", metaKey: true },
|
||||
{ mode: "mod-enter", modKey: "meta", enabled: true },
|
||||
),
|
||||
"Cmd+Enter should submit by default",
|
||||
);
|
||||
assertEquals(
|
||||
shouldSubmitChatKey(
|
||||
{ key: "Enter" },
|
||||
{ mode: "mod-enter", modKey: "meta", enabled: true },
|
||||
),
|
||||
false,
|
||||
"Cmd+Enter should submit when meta is selected",
|
||||
);
|
||||
assertEquals(
|
||||
shouldSubmitChatKey(
|
||||
|
||||
@@ -26,7 +26,7 @@ export type ChatSubmitKeyEventLike = {
|
||||
function normalizeOptions(options: ChatSubmitOptions): NormalizedChatSubmitOptions {
|
||||
return {
|
||||
mode: "mod-enter",
|
||||
modKey: "meta",
|
||||
modKey: "auto",
|
||||
allowEmptySubmit: false,
|
||||
stopPropagation: false,
|
||||
enabled: true,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { buildComposerRequest, parseSigilSegments } from "./composer-command.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("parseSigilSegments converts TUI-style references", () => {
|
||||
assertEquals(parseSigilSegments("read @src/main.rs then #memory and /workflow"), [
|
||||
{ kind: "text", content: "read " },
|
||||
{ kind: "file_ref", path: "src/main.rs" },
|
||||
{ kind: "text", content: " then " },
|
||||
{ kind: "knowledge_ref", slug: "memory" },
|
||||
{ kind: "text", content: " and " },
|
||||
{ kind: "workflow_invoke", slug: "workflow" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("buildComposerRequest sends user segments when sigils are present", () => {
|
||||
const result = buildComposerRequest("inspect @README.md");
|
||||
assert(result.ok, "request should be accepted");
|
||||
assertEquals(result.request?.kind, "user");
|
||||
assertEquals(result.request?.content, "inspect @README.md");
|
||||
assertEquals(result.request?.segments, [
|
||||
{ kind: "text", content: "inspect " },
|
||||
{ kind: "file_ref", path: "README.md" },
|
||||
]);
|
||||
});
|
||||
|
||||
Deno.test("buildComposerRequest parses colon commands", () => {
|
||||
const compact = buildComposerRequest(":compact");
|
||||
assert(compact.ok, "compact should be accepted");
|
||||
assertEquals(compact.request, { kind: "compact", content: "" });
|
||||
|
||||
const peer = buildComposerRequest(":peer companion");
|
||||
assert(peer.ok, "peer should be accepted");
|
||||
assertEquals(peer.request, { kind: "register_peer", content: "companion" });
|
||||
|
||||
const help = buildComposerRequest(":help compact");
|
||||
assert(help.ok, "help should be accepted");
|
||||
assertEquals(help.request, undefined);
|
||||
assert(help.notice?.includes(":compact"), "help should return a local notice");
|
||||
});
|
||||
|
||||
Deno.test("buildComposerRequest rejects invalid colon commands", () => {
|
||||
const unknown = buildComposerRequest(":does-not-exist");
|
||||
assert(!unknown.ok, "unknown command should be rejected");
|
||||
|
||||
const invalidPeer = buildComposerRequest(":peer");
|
||||
assert(!invalidPeer.ok, "invalid peer command should be rejected");
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { Segment } from "$lib/generated/protocol";
|
||||
|
||||
export type WorkerConsoleInputKind =
|
||||
| "user"
|
||||
| "system"
|
||||
| "compact"
|
||||
| "list_rewind_targets"
|
||||
| "register_peer";
|
||||
|
||||
export type WorkerConsoleInputRequest = {
|
||||
kind: WorkerConsoleInputKind;
|
||||
content: string;
|
||||
segments?: Segment[];
|
||||
};
|
||||
|
||||
export type ComposerCommandResult =
|
||||
| { ok: true; request?: WorkerConsoleInputRequest; notice?: string }
|
||||
| { ok: false; message: string };
|
||||
|
||||
type CommandSpec = {
|
||||
usage: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const COMMANDS: Record<string, CommandSpec> = {
|
||||
help: {
|
||||
usage: ":help [command]",
|
||||
description: "Show available Web Console commands or details for one command.",
|
||||
},
|
||||
"?": {
|
||||
usage: ":? [command]",
|
||||
description: "Alias for :help.",
|
||||
},
|
||||
noop: {
|
||||
usage: ":noop",
|
||||
description: "Validate command dispatch without side effects.",
|
||||
},
|
||||
compact: {
|
||||
usage: ":compact",
|
||||
description: "Request immediate Worker context compaction.",
|
||||
},
|
||||
rewind: {
|
||||
usage: ":rewind",
|
||||
description: "Ask the Worker for rewind targets.",
|
||||
},
|
||||
rollback: {
|
||||
usage: ":rollback",
|
||||
description: "Alias for :rewind.",
|
||||
},
|
||||
peer: {
|
||||
usage: ":peer <worker-name>",
|
||||
description: "Register another existing Worker as a reciprocal metadata peer.",
|
||||
},
|
||||
system: {
|
||||
usage: ":system <message>",
|
||||
description: "Send an agent-visible system notification to the Worker.",
|
||||
},
|
||||
};
|
||||
|
||||
export function buildComposerRequest(value: string): ComposerCommandResult {
|
||||
const content = value.trim();
|
||||
if (!content) {
|
||||
return { ok: false, message: "Input is empty." };
|
||||
}
|
||||
if (content.startsWith(":")) {
|
||||
return buildColonCommand(content.slice(1));
|
||||
}
|
||||
const segments = parseSigilSegments(content);
|
||||
return {
|
||||
ok: true,
|
||||
request: {
|
||||
kind: "user",
|
||||
content,
|
||||
segments: segments.some((segment) => segment.kind !== "text") ? segments : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildColonCommand(commandLine: string): ComposerCommandResult {
|
||||
const [name = "", ...argv] = commandLine.trim().split(/\s+/).filter(Boolean);
|
||||
if (!name) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Empty command. Type :help for available commands.",
|
||||
};
|
||||
}
|
||||
switch (name) {
|
||||
case "help":
|
||||
case "?":
|
||||
return helpCommand(argv);
|
||||
case "noop":
|
||||
if (argv.length > 0) {
|
||||
return invalidUsage("noop");
|
||||
}
|
||||
return { ok: true, notice: "noop: no action" };
|
||||
case "compact":
|
||||
if (argv.length > 0) {
|
||||
return invalidUsage("compact");
|
||||
}
|
||||
return { ok: true, request: { kind: "compact", content: "" }, notice: "compact requested" };
|
||||
case "rewind":
|
||||
case "rollback":
|
||||
if (argv.length > 0) {
|
||||
return invalidUsage("rewind");
|
||||
}
|
||||
return { ok: true, request: { kind: "list_rewind_targets", content: "" }, notice: "rewind targets requested" };
|
||||
case "peer":
|
||||
if (argv.length !== 1) {
|
||||
return invalidUsage("peer");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
request: { kind: "register_peer", content: argv[0] },
|
||||
notice: `peer metadata registration requested with \`${argv[0]}\``,
|
||||
};
|
||||
case "system": {
|
||||
const message = commandLine.trim().slice(name.length).trimStart();
|
||||
if (!message) {
|
||||
return invalidUsage("system");
|
||||
}
|
||||
return { ok: true, request: { kind: "system", content: message } };
|
||||
}
|
||||
default:
|
||||
return {
|
||||
ok: false,
|
||||
message: `Unknown command: ${name}. Type :help for available commands.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function helpCommand(argv: string[]): ComposerCommandResult {
|
||||
if (argv.length > 1) {
|
||||
return invalidUsage("help");
|
||||
}
|
||||
const name = argv[0];
|
||||
if (name) {
|
||||
const spec = COMMANDS[name];
|
||||
if (!spec) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Unknown command: ${name}. Type :help for available commands.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
notice: `command: ${name} — usage: ${spec.usage}. ${spec.description}`,
|
||||
};
|
||||
}
|
||||
const list = ["help", "noop", "compact", "rewind", "peer", "system"]
|
||||
.map((command) => `${command} (${COMMANDS[command].usage})`)
|
||||
.join(", ");
|
||||
return {
|
||||
ok: true,
|
||||
notice: `available commands: ${list}`,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidUsage(name: string): ComposerCommandResult {
|
||||
return { ok: false, message: `Invalid arguments. Usage: ${COMMANDS[name].usage}` };
|
||||
}
|
||||
|
||||
export function parseSigilSegments(input: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
const pattern = /(^|\s)([@#/])([^\s]+)/g;
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(input)) !== null) {
|
||||
const leading = match[1] ?? "";
|
||||
const sigil = match[2];
|
||||
const value = match[3];
|
||||
const atomStart = match.index + leading.length;
|
||||
if (atomStart > cursor) {
|
||||
segments.push({ kind: "text", content: input.slice(cursor, atomStart) });
|
||||
}
|
||||
segments.push(sigilSegment(sigil, value));
|
||||
cursor = atomStart + sigil.length + value.length;
|
||||
}
|
||||
if (cursor < input.length) {
|
||||
segments.push({ kind: "text", content: input.slice(cursor) });
|
||||
}
|
||||
return coalesceTextSegments(segments.length > 0 ? segments : [{ kind: "text", content: input }]);
|
||||
}
|
||||
|
||||
function sigilSegment(sigil: string, value: string): Segment {
|
||||
switch (sigil) {
|
||||
case "@":
|
||||
return { kind: "file_ref", path: value };
|
||||
case "#":
|
||||
return { kind: "knowledge_ref", slug: value };
|
||||
case "/":
|
||||
return { kind: "workflow_invoke", slug: value };
|
||||
default:
|
||||
return { kind: "text", content: `${sigil}${value}` };
|
||||
}
|
||||
}
|
||||
|
||||
function coalesceTextSegments(segments: Segment[]): Segment[] {
|
||||
const coalesced: Segment[] = [];
|
||||
for (const segment of segments) {
|
||||
const last = coalesced.at(-1);
|
||||
if (segment.kind === "text" && last?.kind === "text") {
|
||||
last.content += segment.content;
|
||||
} else {
|
||||
coalesced.push(segment);
|
||||
}
|
||||
}
|
||||
return coalesced;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
applyCompletion,
|
||||
completionTokenAt,
|
||||
localCommandCompletions,
|
||||
} from "./composer-completion.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void): void;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
const actualJson = JSON.stringify(actual);
|
||||
const expectedJson = JSON.stringify(expected);
|
||||
if (actualJson !== expectedJson) {
|
||||
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("completionTokenAt detects TUI-style sigils before the cursor", () => {
|
||||
assertEquals(completionTokenAt("open @src/ma", "open @src/ma".length), {
|
||||
sigil: "@",
|
||||
kind: "file",
|
||||
start: 5,
|
||||
end: 12,
|
||||
prefix: "src/ma",
|
||||
});
|
||||
assertEquals(completionTokenAt(":comp", 5)?.kind, "command");
|
||||
assertEquals(completionTokenAt("ask #mem", 8)?.kind, "knowledge");
|
||||
assertEquals(completionTokenAt("run /work", 9)?.kind, "workflow");
|
||||
});
|
||||
|
||||
Deno.test("applyCompletion replaces the active token and advances the cursor", () => {
|
||||
const value = "open @src/ma please";
|
||||
const token = completionTokenAt(value, "open @src/ma".length);
|
||||
assert(token, "token should exist");
|
||||
assertEquals(applyCompletion(value, token, { value: "src/main.rs" }), {
|
||||
value: "open @src/main.rs please",
|
||||
cursor: "open @src/main.rs ".length,
|
||||
});
|
||||
assertEquals(applyCompletion(value, token, { value: "src", is_dir: true }), {
|
||||
value: "open @src/ please",
|
||||
cursor: "open @src/".length,
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("localCommandCompletions filters colon commands", () => {
|
||||
assertEquals(localCommandCompletions("com").map((entry) => entry.value), ["compact"]);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
export type ComposerCompletionKind = "command" | "file" | "knowledge" | "workflow";
|
||||
|
||||
export type ComposerCompletionToken = {
|
||||
sigil: ":" | "@" | "#" | "/";
|
||||
kind: ComposerCompletionKind;
|
||||
start: number;
|
||||
end: number;
|
||||
prefix: string;
|
||||
};
|
||||
|
||||
export type ComposerCompletionEntry = {
|
||||
value: string;
|
||||
is_dir?: boolean;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type CompletionApplyResult = {
|
||||
value: string;
|
||||
cursor: number;
|
||||
};
|
||||
|
||||
export const COLON_COMMAND_COMPLETIONS: ComposerCompletionEntry[] = [
|
||||
{ value: "help", description: "Show commands" },
|
||||
{ value: "noop", description: "No-op" },
|
||||
{ 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: "system", description: "Send system notification" },
|
||||
];
|
||||
|
||||
export function completionTokenAt(
|
||||
value: string,
|
||||
cursor: number,
|
||||
): ComposerCompletionToken | null {
|
||||
const boundedCursor = Math.max(0, Math.min(cursor, value.length));
|
||||
const before = value.slice(0, boundedCursor);
|
||||
const match = /(^|\s)([:@#/])([^\s]*)$/.exec(before);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const sigil = match[2] as ComposerCompletionToken["sigil"];
|
||||
const prefix = match[3] ?? "";
|
||||
const tokenStart = before.length - prefix.length - sigil.length;
|
||||
return {
|
||||
sigil,
|
||||
kind: completionKindForSigil(sigil),
|
||||
start: tokenStart,
|
||||
end: boundedCursor,
|
||||
prefix,
|
||||
};
|
||||
}
|
||||
|
||||
export function localCommandCompletions(prefix: string): ComposerCompletionEntry[] {
|
||||
const normalized = prefix.toLowerCase();
|
||||
return COLON_COMMAND_COMPLETIONS.filter((entry) =>
|
||||
entry.value.toLowerCase().startsWith(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function applyCompletion(
|
||||
value: string,
|
||||
token: ComposerCompletionToken,
|
||||
entry: ComposerCompletionEntry,
|
||||
): CompletionApplyResult {
|
||||
const suffix = entry.is_dir ? "/" : " ";
|
||||
const replacement = `${token.sigil}${entry.value}${suffix}`;
|
||||
const restStart = !entry.is_dir && value[token.end] === " " ? token.end + 1 : token.end;
|
||||
const next = `${value.slice(0, token.start)}${replacement}${value.slice(restStart)}`;
|
||||
const cursor = token.start + replacement.length;
|
||||
return { value: next, cursor };
|
||||
}
|
||||
|
||||
function completionKindForSigil(sigil: ComposerCompletionToken["sigil"]): ComposerCompletionKind {
|
||||
switch (sigil) {
|
||||
case ":":
|
||||
return "command";
|
||||
case "@":
|
||||
return "file";
|
||||
case "#":
|
||||
return "knowledge";
|
||||
case "/":
|
||||
return "workflow";
|
||||
}
|
||||
}
|
||||
@@ -349,6 +349,118 @@ Deno.test("projectConsole keeps Grep error detail in the body", () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole renders alert events", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "alert-1",
|
||||
event: {
|
||||
event: "alert",
|
||||
data: {
|
||||
level: "warn",
|
||||
source: "compactor",
|
||||
message: "manual compaction skipped",
|
||||
timestamp_ms: 1,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "alert-2",
|
||||
event: {
|
||||
event: "alert",
|
||||
data: {
|
||||
level: "error",
|
||||
source: "engine",
|
||||
message: "provider failed",
|
||||
timestamp_ms: 2,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(projection.lines.length, 2);
|
||||
assertEquals(projection.lines[0].kind, "status");
|
||||
assertEquals(projection.lines[0].title, "Alert · compactor");
|
||||
assertEquals(projection.lines[0].body, "manual compaction skipped");
|
||||
assertEquals(projection.lines[0].error, false);
|
||||
assertEquals(projection.lines[1].kind, "error");
|
||||
assertEquals(projection.lines[1].title, "Alert · engine");
|
||||
assertEquals(projection.lines[1].body, "provider failed");
|
||||
assertEquals(projection.lines[1].error, true);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole shows compact progress as a status block", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "compact-1",
|
||||
event: { event: "compact_start" } satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(projection.lines.length, 1);
|
||||
assertEquals(projection.lines[0].id, "status-compact");
|
||||
assertEquals(projection.lines[0].kind, "status");
|
||||
assertEquals(projection.lines[0].body, "Compacting…");
|
||||
assertEquals(projection.lines[0].streaming, true);
|
||||
|
||||
const completed = projectConsole([
|
||||
{
|
||||
eventId: "compact-1",
|
||||
event: { event: "compact_start" } satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "compact-2",
|
||||
event: {
|
||||
event: "compact_done",
|
||||
data: { new_segment_id: "00000000-0000-0000-0000-000000000001" },
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assertEquals(completed.lines.length, 1);
|
||||
assertEquals(completed.lines[0].id, "status-compact");
|
||||
assertEquals(completed.lines[0].body, "Compacted.");
|
||||
assertEquals(completed.lines[0].streaming, false);
|
||||
});
|
||||
|
||||
Deno.test("createConsoleProjector updates only compact status block", () => {
|
||||
const projector = createConsoleProjector();
|
||||
let projection = projector.append([
|
||||
{
|
||||
eventId: "compact-identity-1",
|
||||
event: {
|
||||
event: "user_message",
|
||||
data: { segments: [{ kind: "text", content: "hello" }] },
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "compact-identity-2",
|
||||
event: { event: "compact_start" } satisfies Event,
|
||||
},
|
||||
]);
|
||||
const userLine = projection.lines[0];
|
||||
const compactLine = projection.lines[1];
|
||||
|
||||
projection = projector.append([
|
||||
{
|
||||
eventId: "compact-identity-3",
|
||||
event: {
|
||||
event: "compact_done",
|
||||
data: { new_segment_id: "00000000-0000-0000-0000-000000000001" },
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
assert(
|
||||
projection.lines[0] === userLine,
|
||||
"unrelated message line should keep object identity",
|
||||
);
|
||||
assert(
|
||||
projection.lines[1] !== compactLine,
|
||||
"compact status line should update object identity",
|
||||
);
|
||||
assertEquals(projection.lines[1].body, "Compacted.");
|
||||
});
|
||||
|
||||
Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
@@ -680,6 +792,18 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
||||
is_error: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "extension",
|
||||
ts: 6,
|
||||
domain: "yoi.compaction",
|
||||
payload: {
|
||||
kind: "compaction_block",
|
||||
schema_version: 1,
|
||||
block_id: "compact",
|
||||
state: "running",
|
||||
message: "Compacting…",
|
||||
},
|
||||
},
|
||||
],
|
||||
greeting: {
|
||||
worker_name: "Worker",
|
||||
@@ -710,6 +834,7 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
||||
"user:new user:false",
|
||||
"assistant:assistant reply:false",
|
||||
"tool:Read — 1 file read\n /tmp/a.md:false",
|
||||
"status:Compacting…:true",
|
||||
"in_flight:partial:true",
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Event as ProtocolEvent,
|
||||
Alert,
|
||||
InFlightBlock,
|
||||
InFlightToolCallState,
|
||||
Segment,
|
||||
@@ -262,7 +263,10 @@ export function applyProtocolEvent(
|
||||
case "llm_retry":
|
||||
case "llm_continuation":
|
||||
case "run_end":
|
||||
break;
|
||||
case "alert":
|
||||
appendAlertLine(next, envelope.eventId, event.data);
|
||||
break;
|
||||
case "memory_worker":
|
||||
case "segment_rotated":
|
||||
case "completions":
|
||||
@@ -271,22 +275,23 @@ export function applyProtocolEvent(
|
||||
case "workers_listed":
|
||||
case "worker_restored":
|
||||
case "peer_registered":
|
||||
case "compact_start":
|
||||
case "compact_done":
|
||||
// These are protocol/status/control events. TUI Console does not append
|
||||
// them to the conversation surface; browser Console should not either.
|
||||
break;
|
||||
case "compact_start":
|
||||
upsertStatusLine(next, "compact", envelope.eventId, "Compacting…", true);
|
||||
break;
|
||||
case "compact_done":
|
||||
upsertStatusLine(next, "compact", envelope.eventId, "Compacted.", false);
|
||||
break;
|
||||
case "compact_failed":
|
||||
next.lines.push(
|
||||
line(
|
||||
envelope.eventId,
|
||||
"error",
|
||||
"compact failed",
|
||||
event.data.error,
|
||||
undefined,
|
||||
false,
|
||||
true,
|
||||
),
|
||||
upsertStatusLine(
|
||||
next,
|
||||
"compact",
|
||||
envelope.eventId,
|
||||
`Compact failed: ${event.data.error}`,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
break;
|
||||
case "shutdown":
|
||||
@@ -341,6 +346,52 @@ function line(
|
||||
};
|
||||
}
|
||||
|
||||
function upsertStatusLine(
|
||||
projection: ConsoleProjection,
|
||||
id: string,
|
||||
eventId: string,
|
||||
body: string,
|
||||
streaming: boolean,
|
||||
error = false,
|
||||
): void {
|
||||
const lineId = `status-${id}`;
|
||||
const index = projection.lines.findIndex((item) => item.id === lineId);
|
||||
const item: ConsoleLine = {
|
||||
id: lineId,
|
||||
kind: error ? "error" : "status",
|
||||
title: error ? "Status error" : "Status",
|
||||
body,
|
||||
eventId,
|
||||
source: "event",
|
||||
streaming,
|
||||
error,
|
||||
};
|
||||
if (index >= 0) {
|
||||
projection.lines[index] = item;
|
||||
} else {
|
||||
projection.lines.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
function appendAlertLine(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
alert: Alert,
|
||||
): void {
|
||||
const isError = alert.level === "error";
|
||||
projection.lines.push(
|
||||
line(
|
||||
eventId,
|
||||
isError ? "error" : "status",
|
||||
`Alert · ${alert.source}`,
|
||||
alert.message,
|
||||
undefined,
|
||||
false,
|
||||
isError,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function findLastLineIndex(
|
||||
lines: ConsoleLine[],
|
||||
predicate: (line: ConsoleLine) => boolean,
|
||||
@@ -943,11 +994,55 @@ function applyLogEntry(
|
||||
case "tool_result":
|
||||
applyLoggedItem(projection, eventId, entry["item"]);
|
||||
break;
|
||||
case "extension":
|
||||
applyExtensionEntry(projection, eventId, entry);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function applyExtensionEntry(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
entry: Record<string, unknown>,
|
||||
) {
|
||||
if (entry["domain"] !== "yoi.compaction") {
|
||||
return;
|
||||
}
|
||||
const payload = entry["payload"];
|
||||
if (!isRecord(payload) || payload["kind"] !== "compaction_block") {
|
||||
return;
|
||||
}
|
||||
const blockId = stringField(payload, "block_id") || "compact";
|
||||
const state = stringField(payload, "state") || "running";
|
||||
const message = stringField(payload, "message") || compactMessageForState(state, payload);
|
||||
upsertStatusLine(
|
||||
projection,
|
||||
blockId,
|
||||
eventId,
|
||||
message,
|
||||
state === "running",
|
||||
state === "failed",
|
||||
);
|
||||
}
|
||||
|
||||
function compactMessageForState(
|
||||
state: string,
|
||||
payload: Record<string, unknown>,
|
||||
): string {
|
||||
switch (state) {
|
||||
case "done":
|
||||
return "Compacted.";
|
||||
case "failed": {
|
||||
const error = stringField(payload, "error");
|
||||
return error ? `Compact failed: ${error}` : "Compact failed.";
|
||||
}
|
||||
default:
|
||||
return "Compacting…";
|
||||
}
|
||||
}
|
||||
|
||||
function applyLoggedItem(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
|
||||
+112
-5
@@ -2,6 +2,14 @@
|
||||
import { tick } from 'svelte';
|
||||
import ConsoleLineItem from '$lib/workspace-console/ConsoleLineItem.svelte';
|
||||
import { chatSubmit } from '$lib/workspace-console/chat-submit';
|
||||
import { buildComposerRequest } from '$lib/workspace-console/composer-command';
|
||||
import {
|
||||
applyCompletion,
|
||||
completionTokenAt,
|
||||
localCommandCompletions,
|
||||
type ComposerCompletionEntry,
|
||||
type ComposerCompletionToken
|
||||
} from '$lib/workspace-console/composer-completion';
|
||||
import { fitTextarea } from '$lib/workspace-console/textarea-fit';
|
||||
import {
|
||||
createConsoleProjector,
|
||||
@@ -35,12 +43,24 @@
|
||||
return workspaceApiPath(workspaceId, path);
|
||||
}
|
||||
|
||||
type WorkerCompletionsResult = {
|
||||
kind: 'file' | 'knowledge' | 'workflow';
|
||||
prefix: string;
|
||||
entries: ComposerCompletionEntry[];
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
let worker = $state<Worker | null>(null);
|
||||
let liveWorkerState = $state<string | null>(null);
|
||||
let workerError = $state<string | null>(null);
|
||||
let draft = $state('');
|
||||
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 sendError = $state<string | null>(null);
|
||||
let composerNotice = $state<string | null>(null);
|
||||
let streamState = $state<'connecting' | 'open' | 'closed' | 'error'>('connecting');
|
||||
let streamDiagnostics = $state<Diagnostic[]>([]);
|
||||
let workerDetailsOpen = $state(false);
|
||||
@@ -200,9 +220,80 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
async function applyComposerCompletion(event: KeyboardEvent) {
|
||||
const target = event.currentTarget;
|
||||
if (!(target instanceof HTMLTextAreaElement)) {
|
||||
return;
|
||||
}
|
||||
const token = completionTokenAt(draft, target.selectionStart ?? draft.length);
|
||||
completionToken = token;
|
||||
completionError = null;
|
||||
if (!token) {
|
||||
completionEntries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
completionBusy = true;
|
||||
try {
|
||||
const entries = await resolveCompletionEntries(token);
|
||||
completionEntries = entries;
|
||||
if (entries.length === 0) {
|
||||
completionError = `No completions for ${token.sigil}${token.prefix}`;
|
||||
return;
|
||||
}
|
||||
const applied = applyCompletion(draft, token, entries[0]);
|
||||
draft = applied.value;
|
||||
await tick();
|
||||
target.setSelectionRange(applied.cursor, applied.cursor);
|
||||
composerNotice = entries.length > 1
|
||||
? `Completed ${token.sigil}${entries[0].value}; ${entries.length - 1} more candidate(s)`
|
||||
: null;
|
||||
} 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 result = await postJson<WorkerCompletionsResult>(
|
||||
workerApiPath(
|
||||
`/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/completions`
|
||||
),
|
||||
{ kind: token.kind, prefix: token.prefix }
|
||||
);
|
||||
if (result.diagnostics.length > 0 && result.entries.length === 0) {
|
||||
throw new Error(diagnosticsToText(result.diagnostics));
|
||||
}
|
||||
return result.entries;
|
||||
}
|
||||
|
||||
function handleComposerKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void applyComposerCompletion(event);
|
||||
}
|
||||
|
||||
async function submitDraft(value = draft) {
|
||||
const content = value.trim();
|
||||
if (!content || sending || !inputReady) {
|
||||
const command = buildComposerRequest(value);
|
||||
if (!command.ok) {
|
||||
composerNotice = null;
|
||||
sendError = command.message;
|
||||
return;
|
||||
}
|
||||
composerNotice = command.notice ?? null;
|
||||
if (!command.request) {
|
||||
draft = '';
|
||||
return;
|
||||
}
|
||||
if (sending || !inputReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -211,7 +302,7 @@
|
||||
try {
|
||||
const result = await postJson<WorkerInputResult>(
|
||||
workerApiPath(`/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/input`),
|
||||
{ kind: 'user', content }
|
||||
command.request
|
||||
);
|
||||
if (result.state === 'accepted') {
|
||||
draft = '';
|
||||
@@ -470,18 +561,34 @@
|
||||
<textarea
|
||||
id="worker-console-message"
|
||||
aria-label="Console input"
|
||||
aria-keyshortcuts="Meta+Enter"
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
bind:value={draft}
|
||||
use:chatSubmit={{
|
||||
enabled: inputReady && !sending,
|
||||
onSubmit: (value) => void submitDraft(value)
|
||||
}}
|
||||
use:fitTextarea={{ value: draft, maxRows: 10 }}
|
||||
onkeydown={handleComposerKeydown}
|
||||
disabled={!inputReady || sending}
|
||||
></textarea>
|
||||
{#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 class="composer-actions">
|
||||
<button type="submit" disabled={!canSend}>{sending ? 'Sending…' : 'Send'}</button>
|
||||
{#if sendError}<p class="error">{sendError}</p>{/if}
|
||||
{#if composerNotice}<p class="section-note">{composerNotice}</p>{/if}
|
||||
{#if sendError}<p class="error">{sendError}</p>{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user