diff --git a/CHANGELOG.md b/CHANGELOG.md index e5de8ac..945ee4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +### Features + +- Show Pi background-job progress automatically above the input editor, including + active nodes, paused gates, failures, and retained final status. Keep running + executions visible after live edits remove their node definitions; use `/braid` + for the full flow panel. + ## 0.3.1 — 2026-10-04 ### Features diff --git a/README.md b/README.md index 3c418a1..e9bfdba 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,9 @@ With Pi 1.0.1 and Node.js 22.19+: pi install npm:@chrok/pi-braid ``` -Run `/reload`, ask Pi to analyze a task with Braid, and open `/braid` to inspect -the job. npm installs the exact matching core dependency; no checkout is needed +Run `/reload` and ask Pi to analyze a task with Braid. Background progress appears +automatically above the input editor; open `/braid` for the full live panel. +npm installs the exact matching core dependency; no checkout is needed for published versions. To try the current source checkout, follow the [local Pi installation guide](integrations/pi/README.md#install-this-local-checkout-in-pi). diff --git a/integrations/pi/README.md b/integrations/pi/README.md index 6f91e20..01d7819 100644 --- a/integrations/pi/README.md +++ b/integrations/pi/README.md @@ -163,6 +163,21 @@ scheduler persistence across Pi reloads. Reminders alone do not pause execution. ## Live flow panel +In interactive Pi, background progress appears automatically **above the input +editor** as soon as a job is submitted; no command or polling is needed: + +```text +Braid job-1 · running · 2/5 done · review +``` + +The widget shows node progress, active node IDs, paused gates, and failures. +It shows up to three active jobs, with an overflow count for the rest. When no +jobs are active, the newest job's final completed/failed/cancelled status stays +visible until another job is submitted or the session ends. Counts use the +latest state of each current node definition; failed and skipped nodes count as +done, and reruns or live edits can change progress and totals. RPC, JSON, and +print modes do not create a terminal widget. + Run `/braid` to open the newest job, or `/braid ` to open a specific job. The bordered panel keeps the job header and keyboard controls visible while you scroll the flow and event log. It refreshes as nodes start, finish, fail, diff --git a/integrations/pi/command.ts b/integrations/pi/command.ts index fa697bc..099afad 100644 --- a/integrations/pi/command.ts +++ b/integrations/pi/command.ts @@ -200,6 +200,62 @@ export class BraidPanel implements Component { } } +export function registerBraidWidget(pi: ExtensionAPI, jobs: BraidJobs): () => void { + let dispose = () => {}; + const clear = () => { + dispose(); + dispose = () => {}; + }; + pi.on("session_start", (_event, ctx) => { + clear(); + if (ctx.mode !== "tui") return; + const refresh = () => { + if (!jobs.list().length) { + ctx.ui.setWidget("braid-status", undefined); + return; + } + ctx.ui.setWidget("braid-status", (_tui, theme) => ({ + render(width) { + const all = jobs.list(); + const active = all.filter(job => job.status === "running"); + const selected = active.length ? active.slice(0, 3) : all.slice(0, 1); + const lines = selected.map(job => { + const progress = jobs.progress(job.jobId)!; + const status = job.status === "running" && progress.paused && !progress.running.length + ? "paused" : job.status; + const color = status === "completed" ? "success" + : status === "failed" ? "error" + : status === "cancelled" ? "muted" : "warning"; + const parts = [ + theme.fg("accent", `Braid ${job.handle}`), + theme.fg(color, status), + `${progress.done}/${progress.total} done`, + ]; + if (progress.failed) parts.push(theme.fg("error", `${progress.failed} failed`)); + if (progress.paused && status !== "paused") + parts.push(theme.fg("warning", `${progress.paused} paused`)); + parts.push(plain(progress.running.length ? progress.running.join(", ") : job.goal)); + return truncateToWidth(parts.join(" · "), width, "…"); + }); + if (active.length > selected.length) lines.push(truncateToWidth( + theme.fg("muted", `Braid · +${active.length - selected.length} active jobs · /braid details`), + width, "…", + )); + return lines; + }, + invalidate() {}, + }), { placement: "aboveEditor" }); + }; + const unsubscribe = jobs.subscribe(refresh); + dispose = () => { + unsubscribe(); + ctx.ui.setWidget("braid-status", undefined); + }; + refresh(); + }); + return clear; +} + export function registerBraidCommand(pi: ExtensionAPI, jobs: BraidJobs): void { pi.registerCommand("braid", { description: "Open the live background-job flow panel: /braid [jobId]", diff --git a/integrations/pi/index.ts b/integrations/pi/index.ts index e617352..7232a30 100644 --- a/integrations/pi/index.ts +++ b/integrations/pi/index.ts @@ -10,7 +10,7 @@ import { import type { BraidInput, GraphUpdate, NodeResult } from "@chrok/braid"; import { Text } from "@earendil-works/pi-tui"; import { BraidJobs, type JobSnapshot } from "./jobs.js"; -import { registerBraidCommand } from "./command.js"; +import { registerBraidCommand, registerBraidWidget } from "./command.js"; import { renderGraphCall, renderGraphResult, renderNodeResult } from "./display.js"; const text = (description?: string) => Type.String({ minLength: 1, pattern: "\\S", ...(description ? { description } : {}) }); @@ -476,6 +476,7 @@ export default function braidExtension(pi: ExtensionAPI) { }); const { braidTool, statusTool, cancelTool, updateTool, resumeTool } = createBraidTools(jobs); registerBraidCommand(pi, jobs); + const disposeWidget = registerBraidWidget(pi, jobs); pi.registerTool(braidTool); pi.registerTool(statusTool); pi.registerTool(cancelTool); @@ -484,6 +485,7 @@ export default function braidExtension(pi: ExtensionAPI) { pi.on("session_shutdown", () => { pending.clear(); jobs.dispose(); + disposeWidget(); }); pi.on("before_agent_start", (event) => ({ systemPrompt: `${event.systemPrompt}\n\n## Braid execution policy\n${BRAID_USAGE_GUIDANCE}\n\nFor the current user request, make this delegation choice before using read, grep, find, edit, write, or bash. Completion reminders refer to existing jobs: retrieve their results instead of submitting the same graph again.`, diff --git a/integrations/pi/jobs.ts b/integrations/pi/jobs.ts index fd59e96..7d8fd61 100644 --- a/integrations/pi/jobs.ts +++ b/integrations/pi/jobs.ts @@ -59,6 +59,7 @@ interface Job extends JobSnapshot { done: Promise; usageClaimed: boolean; nodeResults: Map; + runningExecutions: Map; } export interface NodeCompletion { @@ -128,6 +129,7 @@ export class BraidJobs { controller: new AbortController(), done: Promise.resolve(), usageClaimed: false, + runningExecutions: new Map(), nodeResults: new Map(snapshot.nodes.map((node) => [node.id, { id: node.id, status: "pending" }])), }; this.jobs.set(job.jobId, job); @@ -180,6 +182,14 @@ export class BraidJobs { value: { ...event.workspace }, enumerable: true, configurable: true, writable: true, }); } + // Definition edits can remove nodes whose executions are still running. + if (event.type === "node_started" && event.executionId) { + job.runningExecutions.set(event.executionId, event.nodeId); + } else if ((event.type === "node_completed" || event.type === "node_failed") && event.executionId) { + job.runningExecutions.delete(event.executionId); + } else if (event.type === "graph_completed" || event.type === "graph_failed") { + job.runningExecutions.clear(); + } applyEvent(job.live, event); if ("nodeId" in event && Object.hasOwn(job.live.nodes, event.nodeId)) { // The panel keeps short previews; node retrieval needs the complete @@ -243,6 +253,7 @@ export class BraidJobs { job.status = "failed"; job.live.status = "failed"; job.live.pausedExecutionIds = []; + job.runningExecutions.clear(); job.error = error instanceof Error ? error.message : String(error); if (reports.length) job.usage = sumPiUsage(reports); } @@ -274,6 +285,7 @@ export class BraidJobs { done: _done, usageClaimed: _claimed, nodeResults: _nodeResults, + runningExecutions: _runningExecutions, ...snapshot } = job; const copy = structuredClone(snapshot); @@ -326,6 +338,28 @@ export class BraidJobs { .reverse(); } + /** Editor progress without copying outputs, transcripts, or execution snapshots. */ + progress(jobId: string): { + total: number; + done: number; + failed: number; + running: string[]; + paused: number; + } | undefined { + const job = this.lookup(jobId); + if (!job) return undefined; + const nodes = Object.values(job.live.nodes); + return { + total: nodes.length, + done: nodes.filter(node => + node.status === "completed" || node.status === "failed" || node.status === "skipped", + ).length, + failed: nodes.filter(node => node.status === "failed").length, + running: [...new Set(job.runningExecutions.values())], + paused: job.live.pausedExecutionIds?.length ?? 0, + }; + } + cancel(jobId: string): boolean { const job = this.lookup(jobId); if (!job) throw this.unknownJob(jobId); diff --git a/integrations/pi/test/execution-control.test.ts b/integrations/pi/test/execution-control.test.ts index 09ef666..3dd0641 100644 --- a/integrations/pi/test/execution-control.test.ts +++ b/integrations/pi/test/execution-control.test.ts @@ -18,6 +18,9 @@ test("Pi atomically updates a paused job, resumes, and retrieves immutable execu assert.equal(paused.paused, true); assert.ok(paused.executionId); assert.equal(jobs.claimUsage(paused.handle), undefined); + assert.deepEqual(jobs.progress(paused.handle), { + total: 1, done: 1, failed: 0, running: [], paused: 1, + }); const receipt = JSON.parse((submitted.content[0] as { text: string }).text); assert.deepEqual(receipt.graph.nodes, [{ type: "execute", id: "a", pauseAfter: true }]); assert.deepEqual(receipt.graph.edges, []); @@ -36,6 +39,7 @@ test("Pi atomically updates a paused job, resumes, and retrieves immutable execu addEdges: [{ from: "a", executionId: paused.executionId, to: "b" }], resume: [paused.executionId], }, undefined, undefined, ctx); assert.equal(updated.details.execution!.revision, 1); + assert.equal(jobs.progress(paused.handle)!.total, 2); const updateReceipt = JSON.parse((updated.content[0] as { text: string }).text); assert.deepEqual(updateReceipt.graph.nodes, [{ type: "execute", id: "a" }, { type: "execute", id: "b" }]); assert.deepEqual(updateReceipt.graph.edges, [{ from: "a", executionId: paused.executionId, to: "b" }]); @@ -44,6 +48,9 @@ test("Pi atomically updates a paused job, resumes, and retrieves immutable execu assert.equal(exact.output, "old result"); assert.equal(jobs.get(paused.handle)!.result!.executions[paused.executionId]!.node.prompt, "work"); assert.equal(jobs.get(paused.handle)!.result!.nodes.b!.status, "completed"); + assert.deepEqual(jobs.progress(paused.handle), { + total: 2, done: 2, failed: 0, running: [], paused: 0, + }); await assert.rejects(tools.resumeTool.execute("late", { jobId: paused.handle, expectedRevision: 1, executionIds: [paused.executionId] }, undefined, undefined, ctx), /no longer/); assert.ok(jobs.claimUsage(paused.handle)); assert.equal(jobs.claimUsage(paused.handle), undefined); @@ -54,6 +61,12 @@ test("Pi loop reminders and status use a distinct execution ID for each iteratio const ctx = context(async () => response()); t.mock.method(ctx.modelRegistry, "complete", async (_model: unknown, worker: Context) => { const payload = JSON.parse(worker.messages[0]!.content as string) as { nodeId: string; execution: { iteration: number } }; + if (payload.nodeId === "work" && payload.execution.iteration === 2) { + const progress = jobs.progress(jobs.list()[0]!.handle)!; + assert.equal(progress.total, 2); + assert.ok(progress.done < progress.total, "a rerun resets the current definition progress"); + assert.deepEqual(progress.running, ["work"]); + } if (payload.nodeId === "review" && worker.messages.length === 1) return { ...response(), stopReason: "toolUse" as const, content: [{ type: "toolCall" as const, id: "choice", name: "decide", arguments: { choice: payload.execution.iteration === 2 ? "done" : "again" } }], @@ -71,6 +84,10 @@ test("Pi loop reminders and status use a distinct execution ID for each iteratio }, undefined, undefined, ctx); await jobs.wait(job.details!.handle); assert.equal(jobs.get(job.details!.handle)!.status, "completed"); + assert.deepEqual(jobs.progress(job.details!.handle), { + total: 2, done: 2, failed: 0, running: [], paused: 0, + }); + assert.equal(Object.keys(jobs.get(job.details!.handle)!.result!.executions).length, 4); assert.deepEqual(notifications.map(value => value.iteration), [1, 2]); assert.notEqual(notifications[0]!.executionId, notifications[1]!.executionId); const first = await statusTool.execute("first", { jobId: job.details!.handle, executionId: notifications[0]!.executionId }, undefined, undefined, ctx); diff --git a/integrations/pi/test/selection.test.ts b/integrations/pi/test/selection.test.ts index f295ff7..ec0e390 100644 --- a/integrations/pi/test/selection.test.ts +++ b/integrations/pi/test/selection.test.ts @@ -46,6 +46,7 @@ test("extension registers background tools, a panel command, and guidance to wai ["braid", "braid_status", "braid_cancel", "braid_update", "braid_resume"], ); assert.ok(fake.commands.has("braid")); + assert.ok(fake.handlers.has("session_start")); assert.equal(fake.handlers.has("input"), false); assert.equal(fake.handlers.has("tool_call"), false); const prompt = fake.handlers.get("before_agent_start")!( @@ -261,3 +262,28 @@ test( assert.equal(fake.reminders(), 0); }, ); + +test("extension automatically shows editor job status and clears it on shutdown", { timeout: 3_000 }, async (t) => { + const fake = extension(); + const started = deferred(); + const completion = deferred>(); + const ctx = context(async () => { started.resolve(); return completion.promise; }); + let widget: { render(width: number): string[] } | undefined; + ctx.ui = { + setWidget: (_key: string, factory: ((...args: unknown[]) => typeof widget) | undefined) => { + widget = factory?.({}, { fg: (_color: string, value: string) => value }); + }, + } as never; + t.after(() => fake.handlers.get("session_shutdown")!({} as never, ctx)); + fake.handlers.get("session_start")!({} as never, ctx); + assert.equal(widget, undefined); + const tool = fake.tools.get("braid") as ReturnType["braidTool"]; + await tool.execute("call", input, undefined, undefined, ctx); + await started.promise; + assert.match(widget!.render(120).join("\n"), /Braid job-1 · running · 0\/1 done · a/); + completion.resolve(response()); + await fake.reminder.promise; + assert.match(widget!.render(120).join("\n"), /completed · 1\/1 done/); + fake.handlers.get("session_shutdown")!({} as never, ctx); + assert.equal(widget, undefined); +}); diff --git a/integrations/pi/test/widget.test.ts b/integrations/pi/test/widget.test.ts new file mode 100644 index 0000000..521ebc8 --- /dev/null +++ b/integrations/pi/test/widget.test.ts @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import test, { type TestContext } from "node:test"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { Context } from "@earendil-works/pi-ai"; +import { stripTerminalSequences, visibleWidth, type Component } from "@earendil-works/pi-tui"; +import { registerBraidWidget } from "../command.js"; +import { BraidJobs } from "../jobs.js"; +import { context, deferred, input, response } from "./helpers.js"; + +function fixture(t: TestContext) { + const jobs = new BraidJobs(); + let widget: Component | undefined; + let updates = 0; + let placement: string | undefined; + let handler!: (event: never, ctx: ExtensionContext) => void; + const dispose = registerBraidWidget({ + on: (_event: string, callback: typeof handler) => { handler = callback; }, + } as never, jobs); + const ui = { + setWidget: ( + key: string, + factory: ((...args: unknown[]) => Component) | undefined, + options?: { placement: string }, + ) => { + assert.equal(key, "braid-status"); + updates++; + placement = options?.placement; + widget = factory?.({}, { + fg: (_color: string, value: string) => `\x1b[32m${value}\x1b[0m`, + }); + }, + }; + t.after(() => { jobs.dispose(); dispose(); }); + const lines = (width = 120) => widget?.render(width) ?? []; + return { + jobs, + dispose, + start: (mode: ExtensionContext["mode"] = "tui") => handler({} as never, { mode, ui } as never), + lines, + text: (width = 120) => lines(width).map(stripTerminalSequences).join("\n"), + updates: () => updates, + placement: () => placement, + }; +} + +test("editor widget follows node progress automatically and retains the final status", { timeout: 3_000 }, async (t) => { + const view = fixture(t); + view.start(); + assert.deepEqual(view.lines(), []); + const firstStarted = deferred(); + const secondStarted = deferred(); + const first = deferred>(); + const second = deferred>(); + let calls = 0; + const job = view.jobs.start({ + ...input, + nodes: [input.nodes[0]!, { type: "execute", id: "b", prompt: "follow up" }], + edges: [{ from: "a", to: "b" }], + }, {}, context(async () => { + if (++calls === 1) { firstStarted.resolve(); return first.promise; } + secondStarted.resolve(); + return second.promise; + })); + await firstStarted.promise; + assert.equal(view.placement(), "aboveEditor"); + assert.match(view.text(), /Braid job-1 · running · 0\/2 done · a/); + assert.equal(view.jobs.progress("unknown"), undefined); + const snapshots = t.mock.method(view.jobs, "get"); + view.start(); + view.text(); + assert.equal(snapshots.mock.callCount(), 0, "the status widget must not copy full results"); + snapshots.mock.restore(); + first.resolve(response()); + await secondStarted.promise; + assert.match(view.text(), /running · 1\/2 done · b/); + assert.deepEqual(view.jobs.progress(job.handle), { + total: 2, done: 1, failed: 0, running: ["b"], paused: 0, + }); + second.resolve(response()); + await view.jobs.wait(job.handle); + assert.match(view.text(), /Braid job-1 · completed · 2\/2 done/); +}); + +test("editor widget distinguishes a paused gate from independent running work and follows resume", { timeout: 3_000 }, async (t) => { + const view = fixture(t); + view.start(); + const otherStarted = deferred(); + const nextStarted = deferred(); + const other = deferred>(); + const next = deferred>(); + const paused = deferred(); + const fullyPaused = deferred(); + let otherWasStarted = false; + const ctx = context(async () => response()); + t.mock.method(ctx.modelRegistry, "complete", async (_model: unknown, worker: Context) => { + const { nodeId } = JSON.parse(worker.messages[0]!.content as string) as { nodeId: string }; + if (nodeId === "a") return response(); + if (nodeId === "other") { + otherWasStarted = true; + otherStarted.resolve(); + return other.promise; + } + assert.equal(nodeId, "b"); + nextStarted.resolve(); + return next.promise; + }); + const job = view.jobs.start({ + ...input, + nodes: [ + { ...input.nodes[0]!, pauseAfter: true }, + { type: "execute", id: "other", prompt: "independent work" }, + { type: "execute", id: "b", prompt: "follow up" }, + ], + edges: [{ from: "a", to: "b" }], + }, {}, ctx); + const unsubscribe = view.jobs.subscribe(() => { + const progress = view.jobs.progress(job.handle)!; + if (progress.paused) { + paused.resolve(); + if (otherWasStarted && !progress.running.length) fullyPaused.resolve(); + } + }); + t.after(unsubscribe); + await Promise.all([paused.promise, otherStarted.promise]); + assert.match(view.text(), /running · 1\/3 done · 1 paused · other/); + other.resolve(response()); + await fullyPaused.promise; + assert.match(view.text(), /paused · 2\/3 done/); + const execution = view.jobs.get(job.handle)!.execution!; + view.jobs.resume(job.handle, execution.pausedExecutionIds, execution.revision); + await nextStarted.promise; + assert.match(view.text(), /running · 2\/3 done · b/); + next.resolve(response()); + await view.jobs.wait(job.handle); + assert.match(view.text(), /completed · 3\/3 done/); +}); + +test("editor widget tracks a running execution after its definition is removed", { timeout: 3_000 }, async (t) => { + const view = fixture(t); + view.start(); + const otherStarted = deferred(); + const other = deferred>(); + const paused = deferred(); + const fullyPaused = deferred(); + let otherWasStarted = false; + const ctx = context(async () => response()); + t.mock.method(ctx.modelRegistry, "complete", async (_model: unknown, worker: Context) => { + const { nodeId } = JSON.parse(worker.messages[0]!.content as string) as { nodeId: string }; + if (nodeId === "a") return response(); + assert.equal(nodeId, "other"); + otherWasStarted = true; + otherStarted.resolve(); + return other.promise; + }); + const job = view.jobs.start({ + ...input, + nodes: [ + { ...input.nodes[0]!, pauseAfter: true }, + { type: "execute", id: "other", prompt: "independent work" }, + { type: "execute", id: "b", prompt: "follow up" }, + ], + edges: [{ from: "a", to: "b" }], + }, {}, ctx); + const unsubscribe = view.jobs.subscribe(() => { + const progress = view.jobs.progress(job.handle)!; + if (progress.paused) { + paused.resolve(); + if (otherWasStarted && !progress.running.length) fullyPaused.resolve(); + } + }); + t.after(unsubscribe); + await Promise.all([paused.promise, otherStarted.promise]); + const updated = view.jobs.update(job.handle, { + expectedRevision: view.jobs.get(job.handle)!.execution!.revision, + removeNodeIds: ["other"], + }); + assert.equal(Object.hasOwn(updated.live.nodes, "other"), false); + assert.equal(Object.hasOwn(updated, "runningExecutions"), false, "execution tracking stays internal"); + assert.deepEqual(view.jobs.progress(job.handle), { + total: 2, done: 1, failed: 0, running: ["other"], paused: 1, + }); + assert.match(view.text(), /running · 1\/2 done · 1 paused · other/); + other.resolve(response()); + await fullyPaused.promise; + assert.deepEqual(view.jobs.progress(job.handle), { + total: 2, done: 1, failed: 0, running: [], paused: 1, + }); + assert.match(view.text(), /paused · 1\/2 done/); + view.jobs.cancel(job.handle); + await view.jobs.wait(job.handle); + assert.deepEqual(view.jobs.progress(job.handle)!.running, []); +}); + +test("editor widget prioritizes active jobs and bounds concurrent-job rows", { timeout: 3_000 }, async (t) => { + const view = fixture(t); + view.start(); + const ready = deferred(); + const results = Array.from({ length: 5 }, () => deferred>()); + let calls = 0; + const jobs = results.map((result, index) => view.jobs.start( + { ...input, goal: `Task ${index}` }, {}, context(async () => { + if (++calls === results.length) ready.resolve(); + return result.promise; + }), + )); + await ready.promise; + assert.equal(view.lines().length, 4); + assert.match(view.text(), /Braid job-5 · running/); + assert.match(view.text(), /\+2 active jobs · \/braid details/); + results[4]!.resolve(response()); + await view.jobs.wait(jobs[4]!.handle); + assert.doesNotMatch(view.text(), /job-5/); + assert.match(view.text(), /Braid job-4 · running/); + assert.match(view.text(), /\+1 active jobs/); + for (const job of jobs.slice(0, 4)) view.jobs.cancel(job.handle); + await Promise.all(jobs.map(job => view.jobs.wait(job.handle))); + assert.equal(view.lines().length, 1); + assert.match(view.text(), /Braid job-5 · completed/); +}); + +test("editor widget counts skipped nodes, reports failures, and follows cancellation", { timeout: 3_000 }, async (t) => { + const view = fixture(t); + view.start(); + const failed = view.jobs.start({ + ...input, + nodes: [ + { ...input.nodes[0]!, requireSuccess: true }, + { type: "execute", id: "b", prompt: "follow up" }, + ], + edges: [{ from: "a", to: "b" }], + }, {}, context(async () => { throw new Error("offline provider failure"); })); + await view.jobs.wait(failed.handle); + assert.match(view.text(), /Braid job-1 · failed · 2\/2 done · 1 failed/); + const started = deferred(); + const cancelled = view.jobs.start(input, {}, context(async () => { + started.resolve(); + return new Promise(() => {}); + })); + await started.promise; + view.jobs.cancel(cancelled.handle); + await view.jobs.wait(cancelled.handle); + assert.match(view.text(), /Braid job-2 · cancelled/); +}); + +test("editor widget handles Unicode and narrow widths, skips non-TUI modes, and unsubscribes on cleanup", { timeout: 3_000 }, async (t) => { + const view = fixture(t); + for (const mode of ["rpc", "json", "print"] as const) view.start(mode); + assert.equal(view.updates(), 0); + const started = deferred(); + const result = deferred>(); + const job = view.jobs.start({ + ...input, + goal: "Review 中文 🚀\n\x1b[2Jlayout", + nodes: [{ type: "execute", id: "worker 中文 🚀\n\x1b[2Jbad", prompt: "work" }], + }, {}, context(async () => { started.resolve(); return result.promise; })); + await started.promise; + assert.deepEqual(view.lines(), []); + view.start(); + assert.match(view.text(), /worker 中文 🚀 bad/); + for (const width of [0, 1, 8, 20, 40, 120]) { + const lines = view.lines(width); + assert.ok(lines.every(line => visibleWidth(line) <= width)); + assert.ok(lines.every(line => !line.includes("\x1b[2J") && !/[\n\r]/u.test(line))); + } + view.start("rpc"); + assert.deepEqual(view.lines(), []); + const before = view.updates(); + result.resolve(response()); + await view.jobs.wait(job.handle); + assert.equal(view.updates(), before, "the old UI observer must be removed"); + view.start(); + assert.match(view.text(), /completed · 1\/1 done · Review 中文 🚀 layout/); + view.dispose(); + view.dispose(); + assert.deepEqual(view.lines(), []); + const disposedUpdates = view.updates(); + const following = view.jobs.start(input, {}, context(async () => response())); + await view.jobs.wait(following.handle); + assert.equal(view.updates(), disposedUpdates); +});