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(); +}