From 4896b21d22f28f9dba8689d8d4dcd478743be598 Mon Sep 17 00:00:00 2001 From: Kieran Zhang Date: Fri, 2 Oct 2026 16:32:00 +0800 Subject: [PATCH] feat(spike): port pi's durable TUI as a remote client (P1) The upstream durable TUI renders a host's gateway through RemoteDurable unchanged, apart from its imports and a host connection notice. pi internals hidden by the package exports map are imported by relative dist path from one module, keeping stateful modules such as the theme single instances. Adds controller tests (steer, abort, conversation switch) and a pty smoke test. --- .scratch/durable-multiview/HANDOFF.md | 24 +- bun.lock | 8 +- spikes/durable-multiview/cli/host-address.ts | 20 + spikes/durable-multiview/cli/observe.ts | 22 +- spikes/durable-multiview/package.json | 9 +- .../durable-multiview/test/controller.test.ts | 75 ++ spikes/durable-multiview/test/gateway.test.ts | 54 +- spikes/durable-multiview/test/support.ts | 48 ++ spikes/durable-multiview/test/tui.test.ts | 60 ++ spikes/durable-multiview/tsconfig.json | 2 +- spikes/durable-multiview/tui/main.ts | 19 + spikes/durable-multiview/tui/pi-internals.ts | 16 + spikes/durable-multiview/tui/tui.ts | 680 ++++++++++++++++++ 13 files changed, 969 insertions(+), 68 deletions(-) create mode 100644 spikes/durable-multiview/cli/host-address.ts create mode 100644 spikes/durable-multiview/test/controller.test.ts create mode 100644 spikes/durable-multiview/test/tui.test.ts create mode 100644 spikes/durable-multiview/tui/main.ts create mode 100644 spikes/durable-multiview/tui/pi-internals.ts create mode 100644 spikes/durable-multiview/tui/tui.ts diff --git a/.scratch/durable-multiview/HANDOFF.md b/.scratch/durable-multiview/HANDOFF.md index 7cc3992e..0400f5c6 100644 --- a/.scratch/durable-multiview/HANDOFF.md +++ b/.scratch/durable-multiview/HANDOFF.md @@ -1,7 +1,7 @@ # Handoff:Pi Durable 多端同屏 spike - 日期:2026-10-02 -- 状态:P0 完成(见 §10),下一步 P1。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。 +- 状态:P0、P1 完成(见 §10、§11),下一步 P2(Web UI)。PR 栈:P0 [#426](https://github.com/bubbleptr/pace/pull/426) ← P1 `feat/durable-multiview-tui`。前置的 Pi 1.0 升级 [PR #425](https://github.com/bubbleptr/pace/pull/425) 已合入,分支 `feat/durable-multiview-spike` 从 `origin/main` 开出。 - 关联:[docs/research/pi-durable-analysis.md](../../docs/research/pi-durable-analysis.md)(框架分析,用户的写作素材,只在本地工作区,不提交)、[ADR-0040](../../docs/adr/0040-root-session-process-isolation.md)、[ADR-0042](../../docs/adr/0042-pace-as-chord-presentation-host.md)、[ADR-0044](../../docs/adr/0044-single-writer-session-projection-in-renderer.md) - 目的:把上一个会话里已核实的事实、已定的决策和待决问题交给新会话,避免重查。 @@ -185,3 +185,25 @@ Pace 页面放在 `apps/desktop/src/pages/` 下的 dev-only 路由,backend 侧 - **token 要持久化**(数据目录下 `token`,0600)。如果每次启动都重新生成,`kill -9` 重启后客户端就连不上了。鉴权失败时用关闭码 4401,这样浏览器也能读到原因(浏览器 WebSocket 拿不到 HTTP 状态码)。 - **`submit` 带 `requestId`**(客户端生成 UUID),为「回复丢了再重发不重复提交」做准备;目前客户端还不会自动重发。 - §3.1 里与 P4 相关的事实(`TaskOptions.background`、`HookApi` 的能力)本阶段没有复核,P4 用到时再核。 + +## 11. P1 进展(2026-10-02):TUI 远程客户端 + +### 11.1 落地的内容 + +- `tui/tui.ts`:上游 `tui.ts`(earendil-works/pi@7fbbd5f)的移植。保留上游的 tab 缩进,和上游 `diff` 时只显示真正的改动:导入来源、本地的 `agentOf`,以及连接状态提示(`reconnecting` / `closed`)。 +- `tui/pi-internals.ts`:**§8.3 的决定是按路径引用 dist,不拷贝。** 用相对路径 `../node_modules/@earendil-works/pi-coding-agent/dist/...` 引入,Node 和 TypeScript 都不会按 `exports` 检查相对路径;它经过的是和包根同一个符号链接,所以 `theme` 这类有状态的模块仍是单实例(已验证 `initTheme` 是同一个函数)。Pi 升级后路径变了,会在 typecheck 阶段报错。 +- 比交接文档 §3.2 的表多出两个需要走路径引入的符号:`theme`(根入口只导出了 `Theme` 类型和函数),以及 `KeybindingsManager`(根入口只以 `export type` 导出,值要从 `dist/core/keybindings.js` 拿)。 +- `tui/main.ts`:连上宿主后先 `toggleTasks()`,和上游一样默认打开任务面板。主题、快捷键、终端能力用本机的 `SettingsManager`,会话内容来自宿主。 +- `cli/host-address.ts`:观察器和 TUI 共用的 `--url` / `--token` / `--data-dir` 解析。 + +### 11.2 验收 + +- `test/controller.test.ts`:两个客户端,一端驱动、另一端 steer,两端都能看到队列和 steer 后的那一轮;abort 后片段保留为 aborted 条目;切到另一个会话并对它说话,另一端仍停在 main;切到不存在的会话时给出错误通知,当前会话不变。 +- `test/tui.test.ts`:在真实 pty(`@lydell/node-pty`,跑在 vitest 的 Node 上)里启动 TUI,输入的内容能被另一个客户端看到,答案渲染在屏幕上;宿主关闭后显示重连提示,Ctrl+D 以 0 退出。 +- 手动用 headless xterm 抓屏走了一遍:流式输出、任务面板、steer、`/agents`、Esc 中止、`kill -9` 后重连。崩溃那一幕的画面是:半句话 → `reconnecting...` → 宿主重启后半句变成 `Operation aborted`,同一个 `pi.generation` 任务重新流式输出 → 完整答案。 + +### 11.3 新发现 + +- 用测试 API 直接建的 ownerless 会话 `pi.agent` 是空的(没有模型),提交会得到 `no_model`。子代理会话从父会话复制 agent 配置,不受影响。P4 造演示会话时要带上 `agent`。 +- 崩溃的代价是重发一次请求:被打断那次的用量也计入 `pi.usage`(页脚从 `↑6 ↓29` 变成 `↑12 ↓58`)。剧本 §6 第 12 步讲用量时可以顺带提。 +- `/agents` 现在只有 main,因为宿主还没装子代理扩展(P4)。协议层的会话切换已在测试里覆盖。 diff --git a/bun.lock b/bun.lock index 32917495..c106b95b 100644 --- a/bun.lock +++ b/bun.lock @@ -98,11 +98,13 @@ "@earendil-works/pi-ai": "1.0.0", "@earendil-works/pi-coding-agent": "1.0.0", "@earendil-works/pi-durable": "1.0.0", + "@earendil-works/pi-tui": "1.0.0", "proper-lockfile": "4.1.2", "undici": "8.10.2", "ws": "8.22.0", }, "devDependencies": { + "@lydell/node-pty": "^1.2.0-beta.15", "@types/proper-lockfile": "4.1.4", "@types/ws": "8.18.2", }, @@ -1851,7 +1853,7 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - "marked": ["marked@18.0.9", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w=="], + "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], @@ -2657,8 +2659,6 @@ "@earendil-works/pi-durable/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - "@earendil-works/pi-tui/marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], - "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], "@electron/asar/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -2717,6 +2717,8 @@ "@lobehub/ui/lucide-react": ["lucide-react@1.29.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Xs9QFG5+9sNX04MdKVT4++umA+hJ2qsJVlRlRWHQ7qZobXgMiNHSpZ5eZm8JUoGCdNyoEdXoEwa8HVr0DNjOQg=="], + "@lobehub/ui/marked": ["marked@18.0.9", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w=="], + "@lobehub/ui/shiki": ["shiki@4.4.2", "", { "dependencies": { "@shikijs/core": "4.4.2", "@shikijs/engine-javascript": "4.4.2", "@shikijs/engine-oniguruma": "4.4.2", "@shikijs/langs": "4.4.2", "@shikijs/themes": "4.4.2", "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], diff --git a/spikes/durable-multiview/cli/host-address.ts b/spikes/durable-multiview/cli/host-address.ts new file mode 100644 index 00000000..03adfc06 --- /dev/null +++ b/spikes/durable-multiview/cli/host-address.ts @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; + +export const DEFAULT_DATA_DIR = join(getAgentDir(), "experimental", "durable-multiview", "default"); + +/** A client's `--url`, and `--token` or the token file a host on the same machine left in `--data-dir`. */ +export async function hostAddress(argv: readonly string[]): Promise<{ url: string; token: string }> { + const { values } = parseArgs({ + args: [...argv], + options: { + "data-dir": { type: "string" }, + url: { type: "string", default: "ws://127.0.0.1:7420" }, + token: { type: "string" }, + }, + }); + const token = values.token ?? (await readFile(join(resolve(values["data-dir"] ?? DEFAULT_DATA_DIR), "token"), "utf8")).trim(); + return { url: values.url, token }; +} diff --git a/spikes/durable-multiview/cli/observe.ts b/spikes/durable-multiview/cli/observe.ts index 9e8a9c6e..138e4fb6 100644 --- a/spikes/durable-multiview/cli/observe.ts +++ b/spikes/durable-multiview/cli/observe.ts @@ -2,28 +2,16 @@ // A line-mode client of a running host: prints the shown conversation as it // streams, and sends each typed line. Run several to watch one host from many terminals. // -// node cli/observe.ts [--data-dir DIR] [--url ws://127.0.0.1:7420] +// node cli/observe.ts [--data-dir DIR | --token T] [--url ws://127.0.0.1:7420] // typed lines: text (prompt; follow-up while busy), /steer text, /abort, /tasks, /quit -import { readFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; import { createInterface } from "node:readline"; -import { parseArgs } from "node:util"; -import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { connectRemoteDurable } from "../protocol/remote-durable.ts"; import type { DurableView } from "../protocol/view.ts"; +import { hostAddress } from "./host-address.ts"; import { initialPrinter, printIncrement } from "./printer.ts"; -const { values } = parseArgs({ - options: { - "data-dir": { type: "string" }, - url: { type: "string", default: "ws://127.0.0.1:7420" }, - token: { type: "string" }, - }, -}); -const dataDir = resolve(values["data-dir"] ?? join(getAgentDir(), "experimental", "durable-multiview", "default")); -const token = values.token ?? (await readFile(join(dataDir, "token"), "utf8")).trim(); - -const remote = await connectRemoteDurable({ url: values.url, token }); +const address = await hostAddress(process.argv.slice(2)); +const remote = await connectRemoteDurable(address); let printer = initialPrinter; let shown: DurableView | undefined; const render = (): void => { @@ -42,7 +30,7 @@ const render = (): void => { process.stdout.write(step.output); }; remote.view.subscribe(render); -process.stdout.write(`[connected to ${values.url}, session ${remote.view.current().session.id}]\n`); +process.stdout.write(`[connected to ${address.url}, session ${remote.view.current().session.id}]\n`); render(); const input = createInterface({ input: process.stdin }); diff --git a/spikes/durable-multiview/package.json b/spikes/durable-multiview/package.json index 39de8ba6..c774aab9 100644 --- a/spikes/durable-multiview/package.json +++ b/spikes/durable-multiview/package.json @@ -8,7 +8,8 @@ "host": "node host/main.ts", "observe": "node cli/observe.ts", "test": "vitest run --config vitest.config.ts", - "typecheck": "tsc --noEmit -p tsconfig.json" + "typecheck": "tsc --noEmit -p tsconfig.json", + "tui": "node tui/main.ts" }, "dependencies": { "@earendil-works/chord": "1.0.0", @@ -17,10 +18,12 @@ "@earendil-works/pi-durable": "1.0.0", "proper-lockfile": "4.1.2", "undici": "8.10.2", - "ws": "8.22.0" + "ws": "8.22.0", + "@earendil-works/pi-tui": "1.0.0" }, "devDependencies": { "@types/proper-lockfile": "4.1.4", - "@types/ws": "8.18.2" + "@types/ws": "8.18.2", + "@lydell/node-pty": "^1.2.0-beta.15" } } diff --git a/spikes/durable-multiview/test/controller.test.ts b/spikes/durable-multiview/test/controller.test.ts new file mode 100644 index 00000000..672324f8 --- /dev/null +++ b/spikes/durable-multiview/test/controller.test.ts @@ -0,0 +1,75 @@ +import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context"; +import { AgentDoc, type ConversationId } from "@earendil-works/pi-durable"; +import { describe, expect, it } from "vitest"; +import { isBusy, streamingText, transcript } from "../protocol/transcript.ts"; +import { connectTo, LONG_ANSWER, startFauxHost, useCleanups, waitForView } from "./support.ts"; + +const defer = useCleanups(); +const inboxOf = (view: { conversation: { docs: Record } }) => + ((view.conversation.docs["pi.inbox"] ?? { items: [] }) as { items: { mode: string }[] }).items; + +describe("remote controller", () => { + it("steers a busy conversation from another client; both see the queue and the steered turn", async () => { + const host = await startFauxHost(defer, { answers: [LONG_ANSWER, "steered"], tokensPerSecond: 60 }); + const driver = await connectTo(defer, host); + const steerer = await connectTo(defer, host); + + await driver.controller.submit("investigate", "followUp"); + await waitForView(steerer.view, (view) => isBusy(view.conversation)); + await steerer.controller.submit("focus on the logs", "steer"); + await waitForView(driver.view, (view) => inboxOf(view).some((item) => item.mode === "steer")); + + await waitForView(driver.view, (view) => !isBusy(view.conversation) && transcript(view.conversation).at(-1)?.text === "steered"); + expect(transcript(driver.view.current().conversation).map((line) => line.text)).toEqual([ + "investigate", + LONG_ANSWER, + "focus on the logs", + "steered", + ]); + }); + + it("aborts a streaming answer; the partial stays as an aborted entry", async () => { + const host = await startFauxHost(defer, { tokensPerSecond: 40 }); + const client = await connectTo(defer, host); + + await client.controller.submit("investigate", "followUp"); + await waitForView(client.view, (view) => (streamingText(view.conversation)?.length ?? 0) > 10); + await client.controller.abort(); + await waitForView(client.view, (view) => !isBusy(view.conversation)); + + const [user, answer, ...rest] = transcript(client.view.current().conversation); + expect(user).toEqual({ role: "user", text: "investigate" }); + expect(answer).toMatchObject({ role: "assistant", stopReason: "aborted" }); + expect(rest).toEqual([]); + }); + + it("switches to another conversation and talks to it; the other client stays on main", async () => { + const host = await startFauxHost(defer, { answers: ["from the other conversation"] }); + const switcher = await connectTo(defer, host); + const bystander = await connectTo(defer, host); + const rootId = switcher.view.current().conversation.conversation.id; + // An ownerless conversation starts with an empty agent; a subagent's would copy its parent's. + const { model } = (await host.harness.snapshot(AgentDoc, rootId, BACKGROUND_CONTEXT))!; + const other = await host.harness.createConversation({ ownership: { kind: "ownerless" }, agent: { model: model! } }, BACKGROUND_CONTEXT); + + await waitForView(switcher.view, (view) => view.conversations.some((summary) => summary.id === other.id)); + await switcher.controller.switchConversation(other.id); + expect(switcher.view.current().conversation.conversation.id).toBe(other.id); + + await switcher.controller.submit("hello other", "followUp"); + await waitForView(switcher.view, (view) => transcript(view.conversation).at(-1)?.text === "from the other conversation"); + expect(bystander.view.current().conversation.conversation.id).toBe(rootId); + expect(transcript(bystander.view.current().conversation)).toEqual([]); + }); + + it("reports a switch to an unknown conversation and keeps the current one", async () => { + const host = await startFauxHost(defer); + const client = await connectTo(defer, host); + const rootId = client.view.current().conversation.conversation.id; + + await client.controller.switchConversation(999 as ConversationId); + + expect(client.view.current().conversation.conversation.id).toBe(rootId); + expect(client.view.current().notices.at(-1)).toMatchObject({ level: "error", message: expect.stringMatching(/999/) }); + }); +}); diff --git a/spikes/durable-multiview/test/gateway.test.ts b/spikes/durable-multiview/test/gateway.test.ts index 844ab661..ea5aaaad 100644 --- a/spikes/durable-multiview/test/gateway.test.ts +++ b/spikes/durable-multiview/test/gateway.test.ts @@ -1,47 +1,15 @@ -import { createModels } from "@earendil-works/pi-ai/models"; -import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux"; -import { afterEach, describe, expect, it } from "vitest"; -import { openHost, type OpenedHost } from "../host/host.ts"; -import { connectRemoteDurable, type RemoteDurable } from "../protocol/remote-durable.ts"; +import { describe, expect, it } from "vitest"; +import type { RemoteDurable } from "../protocol/remote-durable.ts"; import { streamingText, transcript } from "../protocol/transcript.ts"; -import { LONG_ANSWER, tempDir, waitForView } from "./support.ts"; +import { connectTo, LONG_ANSWER, startFauxHost, useCleanups, waitForView } from "./support.ts"; -const cleanups: (() => Promise | void)[] = []; -afterEach(async () => { - for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); -}); - -async function startFauxHost(): Promise { - const dir = await tempDir(); - cleanups.push(dir.remove); - const faux = fauxProvider({ tokensPerSecond: 200 }); - const models = createModels(); - models.setProvider(faux.provider); - faux.setResponses([fauxAssistantMessage(LONG_ANSWER)]); - const model = faux.getModel(); - const host = await openHost({ - dataDir: dir.path, - cwd: dir.path, - models, - modelSummaries: () => [{ provider: model.provider, modelId: model.id, name: model.name, contextWindow: model.contextWindow }], - initialModel: { provider: model.provider, modelId: model.id }, - port: 0, - }); - cleanups.push(() => host.close()); - return host; -} - -async function connect(host: OpenedHost, token = host.token): Promise { - const client = await connectRemoteDurable({ url: host.url, token }); - cleanups.push(() => client.close()); - return client; -} +const defer = useCleanups(); describe("gateway", () => { it("shows one streamed answer to two clients, driven by either", async () => { - const host = await startFauxHost(); - const a = await connect(host); - const b = await connect(host); + const host = await startFauxHost(defer); + const a = await connectTo(defer, host); + const b = await connectTo(defer, host); expect(b.view.current().conversation.conversation.id).toBe(a.view.current().conversation.conversation.id); const partialsSeenByB: string[] = []; @@ -67,13 +35,13 @@ describe("gateway", () => { }); it("rejects a client with the wrong token", async () => { - const host = await startFauxHost(); - await expect(connect(host, "not-the-token")).rejects.toThrow(/unauthorized/i); + const host = await startFauxHost(defer); + await expect(connectTo(defer, host, "not-the-token")).rejects.toThrow(/unauthorized/i); }); it("lists the conversations and opens the task graph on request", async () => { - const host = await startFauxHost(); - const client = await connect(host); + const host = await startFauxHost(defer); + const client = await connectTo(defer, host); const rootId = client.view.current().conversation.conversation.id; expect(client.view.current().conversations.map((summary) => summary.id)).toEqual([rootId]); diff --git a/spikes/durable-multiview/test/support.ts b/spikes/durable-multiview/test/support.ts index 37e19286..fbb063bd 100644 --- a/spikes/durable-multiview/test/support.ts +++ b/spikes/durable-multiview/test/support.ts @@ -2,8 +2,56 @@ import { mkdtemp, rm } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createModels } from "@earendil-works/pi-ai/models"; +import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux"; +import { afterEach } from "vitest"; +import { openHost, type OpenedHost } from "../host/host.ts"; +import { connectRemoteDurable, type RemoteDurable } from "../protocol/remote-durable.ts"; import type { DurableViewSource } from "../protocol/view.ts"; +/** Cleanups registered during a test, run in reverse after it. */ +export function useCleanups(): (cleanup: () => Promise | void) => void { + const cleanups: (() => Promise | void)[] = []; + afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); + }); + return (cleanup) => void cleanups.push(cleanup); +} + +/** An in-process host on a temporary store whose faux model gives `answers` in order. */ +export async function startFauxHost( + defer: (cleanup: () => Promise | void) => void, + { answers = [LONG_ANSWER], tokensPerSecond = 200 }: { answers?: string[]; tokensPerSecond?: number } = {}, +): Promise { + const dir = await tempDir(); + defer(dir.remove); + const faux = fauxProvider({ tokensPerSecond, tokenSize: { min: 1, max: 1 } }); + const models = createModels(); + models.setProvider(faux.provider); + faux.setResponses(answers.map((answer) => fauxAssistantMessage(answer))); + const model = faux.getModel(); + const host = await openHost({ + dataDir: dir.path, + cwd: dir.path, + models, + modelSummaries: () => [{ provider: model.provider, modelId: model.id, name: model.name, contextWindow: model.contextWindow }], + initialModel: { provider: model.provider, modelId: model.id }, + port: 0, + }); + defer(() => host.close()); + return host; +} + +export async function connectTo( + defer: (cleanup: () => Promise | void) => void, + host: OpenedHost, + token = host.token, +): Promise { + const client = await connectRemoteDurable({ url: host.url, token }); + defer(() => client.close()); + return client; +} + export async function tempDir(): Promise<{ path: string; remove(): Promise }> { const path = await mkdtemp(join(tmpdir(), "durable-multiview-")); return { path, remove: () => rm(path, { recursive: true, force: true }) }; diff --git a/spikes/durable-multiview/test/tui.test.ts b/spikes/durable-multiview/test/tui.test.ts new file mode 100644 index 00000000..14773140 --- /dev/null +++ b/spikes/durable-multiview/test/tui.test.ts @@ -0,0 +1,60 @@ +import { fileURLToPath } from "node:url"; +import { spawn as spawnPty } from "@lydell/node-pty"; +import { expect, it } from "vitest"; +import { transcript } from "../protocol/transcript.ts"; +import { connectTo, startFauxHost, tempDir, useCleanups, waitForView } from "./support.ts"; + +const spikeDir = fileURLToPath(new URL("..", import.meta.url)); +const defer = useCleanups(); +// CSI, OSC, and the other escape sequences of a differential terminal render. +// eslint-disable-next-line no-control-regex +const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g; + +/** The TUI in a real pseudo-terminal, with what it printed so far as plain text. */ +async function startTui(url: string, token: string) { + const agentDir = await tempDir(); + defer(agentDir.remove); + const pty = spawnPty(process.execPath, ["tui/main.ts", "--url", url, "--token", token], { + cols: 140, + rows: 40, + cwd: spikeDir, + // An empty agent dir: no user theme, keybindings, or settings leak in. + env: { ...process.env, PI_CODING_AGENT_DIR: agentDir.path, TERM: "xterm-256color" }, + }); + let screen = ""; + let exited: number | undefined; + pty.onData((data) => (screen += data)); + pty.onExit(({ exitCode }) => (exited = exitCode)); + defer(() => { + if (exited === undefined) pty.kill(); + }); + const until = async (predicate: (text: string) => boolean, what: string, timeoutMs = 15_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (!predicate(screen.replace(ANSI, ""))) { + if (Date.now() > deadline) throw new Error(`TUI never showed ${what}; it printed:\n${screen.replace(ANSI, "").slice(-2000)}`); + if (exited !== undefined) throw new Error(`TUI exited with ${exited} before showing ${what}`); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + }; + return { pty, until, exited: () => exited }; +} + +it("drives the host from the TUI and shows the answer, then the lost host", async () => { + const host = await startFauxHost(defer, { answers: ["pong-from-the-host"] }); + const observer = await connectTo(defer, host); + const tui = await startTui(host.url, host.token); + + await tui.until((text) => text.includes("faux/faux-1"), "the footer"); + tui.pty.write("ping-from-the-tui"); + tui.pty.write("\r"); + + await waitForView(observer.view, (view) => transcript(view.conversation)[0]?.text === "ping-from-the-tui"); + await tui.until((text) => text.includes("pong-from-the-host"), "the answer"); + + await host.close(); + await tui.until((text) => text.includes("reconnecting"), "the reconnect notice"); + + tui.pty.write("\u0004"); + await tui.until(() => tui.exited() !== undefined, "exit", 5000).catch(() => {}); + expect(tui.exited()).toBe(0); +}); diff --git a/spikes/durable-multiview/tsconfig.json b/spikes/durable-multiview/tsconfig.json index f287e780..59c1c3cb 100644 --- a/spikes/durable-multiview/tsconfig.json +++ b/spikes/durable-multiview/tsconfig.json @@ -12,5 +12,5 @@ "noEmit": true, "types": ["node"] }, - "include": ["host", "protocol", "cli", "test", "vitest.config.ts"] + "include": ["host", "protocol", "cli", "tui", "test", "vitest.config.ts"] } diff --git a/spikes/durable-multiview/tui/main.ts b/spikes/durable-multiview/tui/main.ts new file mode 100644 index 00000000..2abf3682 --- /dev/null +++ b/spikes/durable-multiview/tui/main.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env node +// pi's durable TUI as a remote client: the same rendering, fed by a host's gateway +// instead of an in-process Harness. Settings (theme, keybindings, terminal +// capabilities) are this machine's, the conversation is the host's. +// +// node tui/main.ts [--data-dir DIR | --token T] [--url ws://127.0.0.1:7420] +import { SettingsManager } from "@earendil-works/pi-coding-agent"; +import { hostAddress } from "../cli/host-address.ts"; +import { connectRemoteDurable } from "../protocol/remote-durable.ts"; +import { runDurableTui } from "./tui.ts"; + +const remote = await connectRemoteDurable(await hostAddress(process.argv.slice(2))); +// As in pi's durable TUI, the task panel starts open; /tasks hides it. +await remote.controller.toggleTasks(); +try { + await runDurableTui(remote.view, remote.controller, SettingsManager.create(process.cwd())); +} finally { + remote.close(); +} diff --git a/spikes/durable-multiview/tui/pi-internals.ts b/spikes/durable-multiview/tui/pi-internals.ts new file mode 100644 index 00000000..d4139e05 --- /dev/null +++ b/spikes/durable-multiview/tui/pi-internals.ts @@ -0,0 +1,16 @@ +// pi-coding-agent 1.0.0 internals the upstream durable TUI uses but the package's +// `exports` map hides. Imported by relative file path, which neither Node nor +// TypeScript checks against `exports`; the path goes through the same symlink as +// the package root, so modules with state (the theme) stay single instances. +// A Pi upgrade that moves one of these fails typecheck here, not at runtime. +export { createAllToolRenderers } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/tools/renderers/index.js"; +export { formatTokens } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js"; +export { + type StatusIndicator, + WorkingStatusIndicator, +} from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/status-indicator.js"; +export type { ToolRenderers } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/tool-execution.js"; +export { getEditorTheme, theme } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.js"; +// The package root re-exports this name as a type only. +export { KeybindingsManager } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/keybindings.js"; +export { InteractiveThemeController } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme-controller.js"; diff --git a/spikes/durable-multiview/tui/tui.ts b/spikes/durable-multiview/tui/tui.ts new file mode 100644 index 00000000..fcd09a5e --- /dev/null +++ b/spikes/durable-multiview/tui/tui.ts @@ -0,0 +1,680 @@ +// Ported from pi's packages/coding-agent/src/experimental/durable/tui.ts at +// earendil-works/pi@7fbbd5f (MIT, Earendil Works). Kept in upstream's formatting so +// a diff against it shows only the port: imports, and the host connection notice. +import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@earendil-works/pi-ai"; +import { + AssistantMessageComponent, + CustomEditor, + DynamicBorder, + getAgentDir, + getMarkdownTheme, + initTheme, + keyText, + type SettingsManager, + ToolExecutionComponent, + UserMessageComponent, +} from "@earendil-works/pi-coding-agent"; +import type { + AgentState, + ConversationId, + ConversationView, + EntryRecord, + InboxState, + LiveState, + TaskGraph, + TaskGraphNode, + UsageState, +} from "@earendil-works/pi-durable"; +import { + Box, + type Component, + Container, + type Focusable, + fuzzyFilter, + getKeybindings, + Input, + Markdown, + ProcessTerminal, + ScrollView, + type SelectItem, + SelectList, + type SelectListTheme, + Spacer, + setCapabilityOverrides, + setKeybindings, + Text, + TruncatedText, + TuiAltScreen, + VStack, +} from "@earendil-works/pi-tui"; +import type { DurableController, DurableView, DurableViewSource } from "../protocol/view.ts"; +import { + createAllToolRenderers, + formatTokens, + getEditorTheme, + InteractiveThemeController, + KeybindingsManager, + type StatusIndicator, + type ToolRenderers, + theme, + WorkingStatusIndicator, +} from "./pi-internals.ts"; + +/** The agent document of a view; absent while the conversation has none. */ +function agentOf(view: ConversationView): AgentState { + return (view.docs["pi.agent"] ?? {}) as AgentState; +} + +const SELECT_THEME: SelectListTheme = { + selectedPrefix: (text) => theme.fg("accent", text), + selectedText: (text) => theme.fg("accent", text), + description: (text) => theme.fg("muted", text), + scrollInfo: (text) => theme.fg("dim", text), + noMatch: (text) => theme.fg("warning", text), +}; + +/** A filterable list in place of the editor. */ +class ListSelector extends Container implements Focusable { + readonly #input = new Input(); + readonly #listContainer = new Container(); + readonly #items: SelectItem[]; + readonly #onSelect: (value: string) => void; + readonly #onCancel: () => void; + #list: SelectList; + #focused = false; + + constructor(title: string, items: SelectItem[], onSelect: (value: string) => void, onCancel: () => void) { + super(); + this.#items = items; + this.#onSelect = onSelect; + this.#onCancel = onCancel; + this.#list = this.#build(items); + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0)); + this.addChild(this.#input); + this.addChild(new Spacer(1)); + this.addChild(this.#listContainer); + this.addChild(new DynamicBorder()); + } + + get focused(): boolean { + return this.#focused; + } + set focused(value: boolean) { + this.#focused = value; + this.#input.focused = value; + } + + handleInput(data: string): void { + const keybindings = getKeybindings(); + const forwarded = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const; + if (forwarded.some((action) => keybindings.matches(data, action))) { + this.#list.handleInput(data); + return; + } + this.#input.handleInput(data); + const query = this.#input.getValue(); + const filtered = + query.length === 0 ? this.#items : fuzzyFilter(this.#items, query, (item) => `${item.label} ${item.value}`); + this.#list = this.#build(filtered); + } + + #build(items: SelectItem[]): SelectList { + const list = new SelectList(items, 10, SELECT_THEME); + list.onSelect = (item) => this.#onSelect(item.value); + list.onCancel = this.#onCancel; + this.#listContainer.clear(); + this.#listContainer.addChild(list); + return list; + } +} + +/** The summary that replaced earlier context: collapsed to one line until expanded. */ +class CompactionComponent extends Box { + readonly #summary: string; + + constructor(summary: string, expanded: boolean) { + super(1, 1, (text) => theme.bg("customMessageBg", text)); + this.#summary = summary; + this.setExpanded(expanded); + } + + setExpanded(expanded: boolean): void { + this.clear(); + this.addChild(new Text(theme.fg("customMessageLabel", theme.bold("[compaction]")), 0, 0)); + this.addChild(new Spacer(1)); + this.addChild( + expanded + ? new Markdown(this.#summary, 0, 0, getMarkdownTheme(), { + color: (text: string) => theme.fg("customMessageText", text), + }) + : new Text( + theme.fg("customMessageText", "Earlier context summarized (") + + theme.fg("dim", keyText("app.tools.expand")) + + theme.fg("customMessageText", " to expand)"), + 0, + 0, + ), + ); + } +} + +interface Handlers { + submit(text: string): void; + followUp(text: string): void; + abort(): void; + exit(): void; + selectModel(): void; + cycleThinking(): void; +} + +class DurableTui { + static readonly #renderers: Record = createAllToolRenderers(); + readonly #ui: TuiAltScreen; + readonly #chat = new Container(); + readonly #tasks = new Container(); + readonly #queue = new Container(); + readonly #notices = new Container(); + readonly #footer = new Container(); + readonly #footerStats = new Text("", 1, 0); + readonly #footerHints = new Text("", 1, 0); + readonly #editorContainer = new Container(); + readonly #editor: CustomEditor; + readonly #cwd: string; + /** The newest card per call ID; provider call IDs may repeat across turns. */ + readonly #tools = new Map(); + /** Every card shown, also older ones whose call ID a later turn reused. */ + readonly #cards: ToolExecutionComponent[] = []; + /** Call IDs whose cards the streaming answer created; its entry takes them over. */ + readonly #streamingCalls = new Set(); + readonly #summaries: CompactionComponent[] = []; + /** Tool output and summaries shown in full; toggled like pi. */ + #expanded = false; + #renderedEntryIds: number[] = []; + #streaming: AssistantMessageComponent | undefined; + #indicator: StatusIndicator | undefined; + #statusText = ""; + /** Set when the transcript was rebuilt: the next render repaints the screen and shows the end. */ + #rebuilt = false; + #transcript: ScrollView; + + constructor(cwd: string, handlers: Handlers) { + this.#cwd = cwd; + this.#ui = new TuiAltScreen(new ProcessTerminal(), false, getAgentDir()); + const keybindings = KeybindingsManager.create(); + setKeybindings(keybindings); + this.#editor = new CustomEditor(this.#ui, getEditorTheme(), keybindings, { + paddingX: 1, + embedWorkingStatus: true, + }); + this.#editor.onSubmit = handlers.submit; + this.#editor.onEscape = handlers.abort; + this.#editor.onCtrlD = handlers.exit; + this.#editor.onAction("app.clear", handlers.exit); + this.#editor.onAction("app.model.select", handlers.selectModel); + this.#editor.onAction("app.thinking.cycle", handlers.cycleThinking); + this.#editor.onAction("app.tools.expand", () => { + this.#expanded = !this.#expanded; + for (const component of [...this.#cards, ...this.#summaries]) component.setExpanded(this.#expanded); + this.#ui.requestRender(); + }); + this.#editor.onAction("app.message.followUp", () => { + const text = this.#editor.getText().trim(); + if (!text) return; + this.#editor.setText(""); + handlers.followUp(text); + }); + + this.#editorContainer.addChild(this.#editor); + this.#footer.addChild(this.#footerStats); + this.#footer.addChild(this.#footerHints); + // One empty line between the transcript and everything below it. + const content = new Container(); + content.addChild(this.#chat); + content.addChild(new Spacer(1)); + const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" }); + this.#transcript = transcript; + const dock = new VStack([ + { component: this.#tasks, shrink: 1, minSize: 0 }, + { component: this.#queue, shrink: 1, minSize: 0 }, + { component: this.#notices, shrink: 1, minSize: 0 }, + { component: this.#editorContainer, shrink: 1, minSize: 3 }, + { component: this.#footer, shrink: 1, minSize: 0 }, + ]); + for (const component of [ + this.#chat, + this.#tasks, + this.#queue, + this.#notices, + this.#editorContainer, + this.#footer, + ]) { + this.#ui.addChild(component); + } + this.#ui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 }, + { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, + ]), + ); + this.#ui.setFocus(this.#editor); + } + + get ui(): TuiAltScreen { + return this.#ui; + } + + start(): void { + this.#ui.start(); + } + + stop(): void { + this.#discardTools(); + this.#indicator?.dispose(); + this.#ui.stop(); + } + + /** Finish every card: a running bash card keeps a timer until it gets a final result. */ + #discardTools(): void { + for (const component of this.#cards) component.updateResult({ content: [], isError: false }, false); + this.#cards.length = 0; + this.#tools.clear(); + this.#streamingCalls.clear(); + } + + mount(component: Component): void { + this.#editorContainer.clear(); + this.#editorContainer.addChild(component); + this.#ui.setFocus(component); + this.#ui.requestRender(); + } + + restoreEditor(): void { + this.mount(this.#editor); + } + + apply(view: DurableView): void { + const live = (view.conversation.docs["pi.live"] ?? {}) as LiveState; + this.#syncTranscript(view.conversation.entries); + const message = live.generation?.message as AssistantMessage | undefined; + // A partial without its entry was dropped, for example by a retry: render the transcript again. + if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.conversation.entries); + if (message !== undefined) this.#syncStreaming(message); + for (const slot of live.tools ?? []) { + if (slot.status === "pending") continue; + const component = this.#tool(slot.name, slot.callId); + component.setArgsComplete(); + if (slot.status !== "running") continue; + component.markExecutionStarted(); + const child = (slot.details as { conversationId?: number } | undefined)?.conversationId; + if (slot.output === undefined && child !== undefined) { + const text = `Subagent ${child} is working. /agents switches to it.`; + component.updateResult({ content: [{ type: "text", text }], details: slot.details, isError: false }, true); + } else if (slot.output !== undefined) { + component.updateResult( + { content: [{ type: "text", text: slot.output }], details: slot.details, isError: false }, + true, + ); + } + } + this.#syncTasks(view.tasks); + this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) as InboxState); + this.#syncNotices(view); + this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off"); + this.#syncStatus(live); + this.#syncFooter(view); + if (this.#rebuilt) this.#transcript.scrollToEnd(); + this.#ui.requestRender(this.#rebuilt); + this.#rebuilt = false; + } + + #syncTasks(graph: TaskGraph | undefined): void { + this.#tasks.clear(); + if (graph === undefined) return; + const nodes = Object.values(graph.tasks); + const lines: string[] = [theme.fg("accent", `Tasks (${nodes.length} live, /tasks to hide)`)]; + // A conversation-owned task sits under the task that owns its conversation, when that task is live. + const owned = new Set(nodes.flatMap((node) => node.conversations)); + const children = (node: TaskGraphNode) => + nodes.filter( + (candidate) => + candidate.owner === node.id || + (candidate.owner === undefined && node.conversations.includes(candidate.conversationId)), + ); + const visit = (node: TaskGraphNode, depth: number): void => { + lines.push(`${" ".repeat(depth + 1)}${describeTask(node)}`); + for (const child of children(node)) visit(child, depth + 1); + }; + for (const node of nodes) { + if (node.owner === undefined && !owned.has(node.conversationId)) visit(node, 0); + } + for (const line of lines) this.#tasks.addChild(new TruncatedText(theme.fg("muted", line), 1, 0)); + } + + #syncQueue(inbox: InboxState): void { + this.#queue.clear(); + for (const item of inbox.items) { + const text = + item.mode === "write" ? `<${String(item.entry.kind)}>` : userText(item.content as UserMessage["content"]); + this.#queue.addChild(new TruncatedText(theme.fg("muted", `[${item.mode}] ${text}`), 1, 0)); + } + } + + #syncNotices(view: DurableView): void { + this.#notices.clear(); + // The view keeps the last state the host sent until it is back. + if (view.connection === "reconnecting") { + this.#notices.addChild(new TruncatedText(theme.fg("warning", "Host connection lost; reconnecting..."), 1, 0)); + } else if (view.connection === "closed") { + this.#notices.addChild(new TruncatedText(theme.fg("error", "Disconnected from the host."), 1, 0)); + } + for (const item of view.notices.slice(-4)) { + const color = item.level === "error" ? "error" : item.level === "warning" ? "warning" : "muted"; + this.#notices.addChild(new TruncatedText(theme.fg(color, item.message), 1, 0)); + } + } + + #syncStatus(live: LiveState): void { + const generation = live.generation; + const compaction = live.compactions?.[0]; + const runningTool = live.tools?.find((slot) => slot.status === "running"); + let text = ""; + if (generation?.retry !== undefined) { + text = `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`; + } else if (generation?.deferred !== undefined) text = "Waiting for deferred response..."; + else if (compaction !== undefined) { + text = compaction.retry + ? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...` + : `Compacting (${compaction.reason})...`; + } else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`; + else if (live.run !== undefined) text = "Working... (esc to abort)"; + if (text === this.#statusText) return; + this.#statusText = text; + this.#indicator?.dispose(); + this.#indicator = text + ? new WorkingStatusIndicator(this.#ui, text, undefined, (part) => this.#editor.borderColor(part)) + : undefined; + this.#editor.setWorkingStatusIndicator(this.#indicator); + } + + #syncFooter(view: DurableView): void { + const agent = agentOf(view.conversation); + const usage = totalUsage((view.conversation.docs["pi.usage"] ?? { models: {}, tools: {} }) as UsageState); + const stats: string[] = []; + if (usage.input) stats.push(`↑${formatTokens(usage.input)}`); + if (usage.output) stats.push(`↓${formatTokens(usage.output)}`); + if (usage.cacheRead) stats.push(`R${formatTokens(usage.cacheRead)}`); + if (usage.cacheWrite) stats.push(`W${formatTokens(usage.cacheWrite)}`); + stats.push(`$${usage.cost.total.toFixed(3)}`); + const contextWindow = + view.models.find((model) => model.provider === agent.model?.provider && model.modelId === agent.model.modelId) + ?.contextWindow ?? 0; + if (contextWindow > 0) { + const tokens = contextTokens(view.conversation.entries); + const percent = tokens === undefined ? undefined : (tokens / contextWindow) * 100; + const text = `${percent === undefined ? "?" : percent.toFixed(1)}%/${formatTokens(contextWindow)}`; + stats.push(percent !== undefined && percent > 90 ? theme.fg("error", text) : text); + } + this.#footerStats.setText(theme.fg("dim", `${stats.join(" ")} ${view.session.cwd}`)); + const model = agent.model === undefined ? "no model" : `${agent.model.provider}/${agent.model.modelId}`; + const shown = view.conversations.find((candidate) => candidate.id === view.conversation.conversation.id); + const label = shown?.label ?? `conversation ${view.conversation.conversation.id}`; + this.#footerHints.setText( + `${theme.fg(label === "main" ? "dim" : "accent", label)}${theme.fg( + "dim", + ` · ${model} · thinking:${agent.thinkingLevel ?? "off"} (${keyText("app.thinking.cycle")}) · ${keyText("app.model.select")} or /model · /agents · /compact · /tasks · ${keyText("app.message.followUp")} follow-up · ${keyText("app.clear")} exit`, + )}`, + ); + } + + #syncTranscript(entries: readonly EntryRecord[]): void { + // Compaction and resets replace the head of the active transcript. + if (this.#renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.#rebuild(entries); + for (const entry of entries.slice(this.#renderedEntryIds.length)) { + this.#addEntry(entry); + this.#renderedEntryIds.push(entry.id); + } + } + + #rebuild(entries: readonly EntryRecord[]): void { + this.#chat.clear(); + this.#discardTools(); + this.#summaries.length = 0; + this.#rebuilt = true; + this.#renderedEntryIds = []; + this.#streaming = undefined; + for (const entry of entries) { + this.#addEntry(entry); + this.#renderedEntryIds.push(entry.id); + } + } + + #addEntry(entry: EntryRecord): void { + const message = entry.model?.[0]; + if (entry.kind === "pi.user" && message?.role === "user") { + this.#chat.addChild(new Spacer(1)); + this.#chat.addChild(new UserMessageComponent(userText(message.content))); + } else if (entry.kind === "pi.assistant" && message?.role === "assistant") { + const component = this.#streaming ?? new AssistantMessageComponent(); + if (this.#streaming === undefined) this.#chat.addChild(component); + this.#streaming = undefined; + component.updateContent(message, false); + // Only a tool-calling answer runs its calls; an aborted, failed, or truncated one never does. + const ran = message.stopReason === "toolUse"; + for (const content of message.content) { + if (content.type !== "toolCall") continue; + const streamed = this.#streamingCalls.has(content.id); + // Only cards the stream already showed are kept for calls that never run. + if (!ran && !streamed) continue; + const card = this.#tool(content.name, content.id, content.arguments, !streamed); + card.setArgsComplete(); + if (!ran) { + const text = "Not run: the answer was interrupted."; + card.updateResult({ content: [{ type: "text", text }], isError: true }, false); + } + } + this.#streamingCalls.clear(); + } else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") { + const result = message as ToolResultMessage; + this.#tool(result.toolName, result.toolCallId).updateResult(result); + } else if (entry.kind === "pi.compaction") { + const summary = new CompactionComponent( + message?.role === "user" ? userText(message.content) : "", + this.#expanded, + ); + this.#summaries.push(summary); + this.#chat.addChild(new Spacer(1)); + this.#chat.addChild(summary); + } else if (entry.kind === "pi.reset") this.#addText("[new context]"); + } + + #syncStreaming(message: AssistantMessage): void { + if (this.#streaming === undefined) { + this.#streaming = new AssistantMessageComponent(); + this.#chat.addChild(this.#streaming); + } + this.#streaming.updateContent(message, true); + for (const content of message.content) { + if (content.type !== "toolCall") continue; + this.#tool(content.name, content.id, content.arguments, !this.#streamingCalls.has(content.id)); + this.#streamingCalls.add(content.id); + } + } + + #addText(text: string): void { + this.#chat.addChild(new Spacer(1)); + this.#chat.addChild(new Text(theme.fg("muted", text), 1, 0)); + } + + /** The card of a call; `fresh` starts a new one for a call ID an earlier turn used. */ + #tool(name: string, callId: string, args?: unknown, fresh = false): ToolExecutionComponent { + const existing = fresh ? undefined : this.#tools.get(callId); + if (existing !== undefined) { + if (args !== undefined) existing.updateArgs(args); + return existing; + } + const component = new ToolExecutionComponent( + name, + callId, + args ?? {}, + {}, + DurableTui.#renderers[name], + this.#ui, + this.#cwd, + ); + component.setExpanded(this.#expanded); + this.#chat.addChild(component); + this.#cards.push(component); + this.#tools.set(callId, component); + return component; + } +} + +function describeTask(node: TaskGraphNode): string { + const state = node.state; + const status = + state.status === "waiting" + ? `waiting on ${state.on.join(", ")}` + : state.status === "completing" + ? `completing (${state.outcome})` + : `${state.status} ${state.phase}`; + const flags = [node.background ? "background" : "", node.abortRequested ? "aborting" : ""].filter(Boolean); + const owned = node.conversations.length > 0 ? ` owns conversation ${node.conversations.join(", ")}` : ""; + return `${node.kind} #${node.id}: ${status}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${owned}`; +} + +function userText(content: UserMessage["content"]): string { + if (typeof content === "string") return content; + return content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join(""); +} + +function totalUsage(state: UsageState): Usage { + const total: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; + for (const usage of [...Object.values(state.models), ...Object.values(state.tools)]) { + total.input += usage.input; + total.output += usage.output; + total.cacheRead += usage.cacheRead; + total.cacheWrite += usage.cacheWrite; + total.cost.total += usage.cost.total; + } + return total; +} + +/** Context size from the newest successful answer after the newest compaction; unknown before one. */ +function contextTokens(entries: readonly EntryRecord[]): number | undefined { + // Kept entries follow the summary in the view but are older than it; only later answers measure the new context. + const compacted = Math.max(0, ...entries.filter((entry) => entry.kind === "pi.compaction").map((entry) => entry.id)); + for (const entry of [...entries].reverse()) { + const message = entry.model?.[0]; + if (entry.id < compacted || entry.kind !== "pi.assistant" || message?.role !== "assistant") continue; + if (message.stopReason === "aborted" || message.stopReason === "error") continue; + const usage = message.usage; + return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite; + } + return undefined; +} + +export async function runDurableTui( + source: DurableViewSource, + controller: DurableController, + settings: SettingsManager, +): Promise { + setCapabilityOverrides(settings.getTerminalCapabilityOverrides()); + // The system theme until the controller resolves the user's theme against the terminal's colors. + initTheme(); + let exit = (): void => {}; + const exited = new Promise((resolve) => { + exit = resolve; + }); + let view!: DurableTui; + + const selectModel = (): void => { + const snapshot = source.current(); + const current = agentOf(snapshot.conversation).model; + const isCurrent = (model: { provider: string; modelId: string }) => + model.provider === current?.provider && model.modelId === current.modelId; + const items: SelectItem[] = [...snapshot.models] + .sort((left, right) => Number(isCurrent(right)) - Number(isCurrent(left))) + .map((model) => ({ + value: `${model.provider}/${model.modelId}`, + label: model.modelId, + description: model.provider, + })); + const selector = new ListSelector( + "Select model:", + items, + (value) => { + view.restoreEditor(); + const separator = value.indexOf("/"); + void controller.setModel({ provider: value.slice(0, separator), modelId: value.slice(separator + 1) }); + }, + () => view.restoreEditor(), + ); + view.mount(selector); + }; + + const selectConversation = (): void => { + const snapshot = source.current(); + const items: SelectItem[] = [...snapshot.conversations].reverse().map((candidate) => ({ + value: String(candidate.id), + label: candidate.label, + description: `${candidate.id === snapshot.conversation.conversation.id ? "(shown) " : ""}${candidate.title ?? ""}`, + })); + const selector = new ListSelector( + "Switch to:", + items, + (value) => { + view.restoreEditor(); + void controller.switchConversation(Number(value) as ConversationId); + }, + () => view.restoreEditor(), + ); + view.mount(selector); + }; + + view = new DurableTui(source.current().session.cwd, { + submit: (text) => { + const trimmed = text.trim(); + if (!trimmed) return; + if (trimmed === "/model") return selectModel(); + if (trimmed === "/tasks") return void controller.toggleTasks(); + if (trimmed === "/agents") return selectConversation(); + if (trimmed === "/compact" || trimmed.startsWith("/compact ")) { + const instructions = trimmed.slice("/compact".length).trim(); + return void controller.compact(instructions || undefined); + } + void controller.submit(trimmed, "steer"); + }, + followUp: (text) => void controller.submit(text, "followUp"), + abort: () => void controller.abort(), + exit, + selectModel, + cycleThinking: () => void controller.cycleThinking(), + }); + + // pi's theme handling: the theme setting (also light/dark pairs) resolved against the terminal's reported colors. + const themes = new InteractiveThemeController(view.ui, { + getSettingsManager: () => settings, + showError: (message) => console.error(message), + onChanged: () => view.ui.requestRender(), + }); + const unsubscribe = source.subscribe(() => view.apply(source.current())); + view.start(); + themes.applyFromSettings(); + view.apply(source.current()); + await exited; + unsubscribe(); + themes.dispose(); + view.stop(); +}