@junghanacs/entwurf 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +3 -2
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +46 -36
  4. package/VERIFY.md +35 -16
  5. package/demo/README.md +1 -1
  6. package/docs/acp-backend-rail.md +1 -1
  7. package/docs/setup-clean-host.md +112 -95
  8. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +1 -1
  9. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +1 -1
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +8 -10
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-fresh-preflight.js +3 -3
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-durable-runtime.js +153 -205
  13. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +6 -4
  14. package/mcp/entwurf-bridge/src/index.ts +1 -1
  15. package/package.json +14 -17
  16. package/pi/pi-durable/bootstrap.mjs +142 -19
  17. package/pi/pi-durable/carrier/LICENSE +21 -0
  18. package/pi/pi-durable/carrier/harness-setup.js +123 -0
  19. package/pi/pi-durable/carrier/prompt.js +74 -0
  20. package/pi/pi-durable/carrier/runtime.js +402 -0
  21. package/pi/pi-durable/carrier/sessions.js +56 -0
  22. package/pi/pi-durable/carrier/subagent.js +55 -0
  23. package/pi/pi-durable/carrier/tui.js +655 -0
  24. package/pi/pi-durable/carrier-relocation.mjs +160 -0
  25. package/pi/pi-durable/carrier-resolver.mjs +38 -0
  26. package/pi/pi-durable/overlay/upstream-pin.json +103 -11
  27. package/pi-extensions/lib/fresh-call-composition.ts +1 -1
  28. package/pi-extensions/lib/mux-fresh-call.ts +8 -10
  29. package/pi-extensions/lib/pi-durable-fresh-preflight.ts +3 -3
  30. package/pi-extensions/lib/pi-durable-runtime.ts +197 -263
  31. package/pi-extensions/meta-bridge-pi-durable.ts +11 -7
  32. package/run.sh +508 -235
  33. package/scripts/check-entwurf-bridge-pi-free.ts +7 -5
  34. package/scripts/check-fresh-call-dispatch.ts +1 -1
  35. package/scripts/check-gate-qualification.ts +86 -34
  36. package/scripts/check-herdr-activation.ts +5 -4
  37. package/scripts/check-pi-durable-contact.ts +26 -93
  38. package/scripts/check-pi-durable-receive.ts +22 -59
  39. package/scripts/check-pi-durable-send.ts +235 -75
  40. package/scripts/emit-pi-durable-carrier.ts +355 -0
  41. package/scripts/lib/mutation-qualify.ts +21 -45
  42. package/scripts/lib/pi-durable-operator-guard.ts +89 -0
  43. package/scripts/lib/qualification-receipt.ts +7 -11
  44. package/scripts/mutants/control-send-receipt.json +1 -1
  45. package/scripts/mutants/gate-qualification.json +13 -0
  46. package/scripts/mutants/mux-fresh-call.json +13 -13
  47. package/scripts/mutants/pack-install.json +3 -3
  48. package/scripts/mutants/pi-durable.json +442 -109
  49. package/scripts/mutants/pi-floor.json +24 -2
  50. package/scripts/pi-durable-contact-host.mjs +3 -3
  51. package/scripts/pi-durable-receive-host.mjs +3 -3
  52. package/scripts/pi-durable-send-host.mjs +76 -5
  53. package/scripts/smoke-herdr-raw-install-live.sh +1 -1
  54. package/scripts/smoke-pi-durable-fresh-live.ts +3 -3
  55. package/scripts/smoke-setup-verdict.sh +15 -15
  56. package/scripts/smoke-user-scope-citizen.sh +1 -1
@@ -0,0 +1,655 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+ import {
12
+ Box,
13
+
14
+ Container,
15
+
16
+ fuzzyFilter,
17
+ getKeybindings,
18
+ Input,
19
+ Markdown,
20
+ ProcessTerminal,
21
+ ScrollView,
22
+
23
+ SelectList,
24
+
25
+ Spacer,
26
+ setCapabilityOverrides,
27
+ setKeybindings,
28
+ Text,
29
+ TruncatedText,
30
+ TuiAltScreen,
31
+ VStack,
32
+ } from "@earendil-works/pi-tui";
33
+ import { getAgentDir } from "entwurf-pi-dist:config.js";
34
+ import { KeybindingsManager } from "entwurf-pi-dist:core/keybindings.js";
35
+
36
+ import { createAllToolRenderers } from "entwurf-pi-dist:core/tools/renderers/index.js";
37
+ import { AssistantMessageComponent } from "entwurf-pi-dist:modes/interactive/components/assistant-message.js";
38
+ import { CustomEditor } from "entwurf-pi-dist:modes/interactive/components/custom-editor.js";
39
+ import { DynamicBorder } from "entwurf-pi-dist:modes/interactive/components/dynamic-border.js";
40
+ import { formatTokens } from "entwurf-pi-dist:modes/interactive/components/footer.js";
41
+ import { keyText } from "entwurf-pi-dist:modes/interactive/components/keybinding-hints.js";
42
+ import { WorkingStatusIndicator } from "entwurf-pi-dist:modes/interactive/components/status-indicator.js";
43
+ import { ToolExecutionComponent, } from "entwurf-pi-dist:modes/interactive/components/tool-execution.js";
44
+ import { UserMessageComponent } from "entwurf-pi-dist:modes/interactive/components/user-message.js";
45
+ import { getEditorTheme, getMarkdownTheme, initTheme, theme } from "entwurf-pi-dist:modes/interactive/theme/theme.js";
46
+ import { InteractiveThemeController } from "entwurf-pi-dist:modes/interactive/theme/theme-controller.js";
47
+ import { agentOf, } from "./runtime.js";
48
+
49
+ const SELECT_THEME = {
50
+ selectedPrefix: (text) => theme.fg("accent", text),
51
+ selectedText: (text) => theme.fg("accent", text),
52
+ description: (text) => theme.fg("muted", text),
53
+ scrollInfo: (text) => theme.fg("dim", text),
54
+ noMatch: (text) => theme.fg("warning", text),
55
+ };
56
+
57
+ /** A filterable list in place of the editor. */
58
+ class ListSelector extends Container {
59
+ #input = new Input();
60
+ #listContainer = new Container();
61
+ #items ;
62
+ #onSelect ;
63
+ #onCancel ;
64
+ #list ;
65
+ #focused = false;
66
+
67
+ constructor(title , items , onSelect , onCancel ) {
68
+ super();
69
+ this.#items = items;
70
+ this.#onSelect = onSelect;
71
+ this.#onCancel = onCancel;
72
+ this.#list = this.#build(items);
73
+ this.addChild(new DynamicBorder());
74
+ this.addChild(new Spacer(1));
75
+ this.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
76
+ this.addChild(this.#input);
77
+ this.addChild(new Spacer(1));
78
+ this.addChild(this.#listContainer);
79
+ this.addChild(new DynamicBorder());
80
+ }
81
+
82
+ get focused() {
83
+ return this.#focused;
84
+ }
85
+ set focused(value ) {
86
+ this.#focused = value;
87
+ this.#input.focused = value;
88
+ }
89
+
90
+ handleInput(data ) {
91
+ const keybindings = getKeybindings();
92
+ const forwarded = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] ;
93
+ if (forwarded.some((action) => keybindings.matches(data, action))) {
94
+ this.#list.handleInput(data);
95
+ return;
96
+ }
97
+ this.#input.handleInput(data);
98
+ const query = this.#input.getValue();
99
+ const filtered =
100
+ query.length === 0 ? this.#items : fuzzyFilter(this.#items, query, (item) => `${item.label} ${item.value}`);
101
+ this.#list = this.#build(filtered);
102
+ }
103
+
104
+ #build(items ) {
105
+ const list = new SelectList(items, 10, SELECT_THEME);
106
+ list.onSelect = (item) => this.#onSelect(item.value);
107
+ list.onCancel = this.#onCancel;
108
+ this.#listContainer.clear();
109
+ this.#listContainer.addChild(list);
110
+ return list;
111
+ }
112
+ }
113
+
114
+ /** The summary that replaced earlier context: collapsed to one line until expanded. */
115
+ class CompactionComponent extends Box {
116
+ #summary ;
117
+
118
+ constructor(summary , expanded ) {
119
+ super(1, 1, (text) => theme.bg("customMessageBg", text));
120
+ this.#summary = summary;
121
+ this.setExpanded(expanded);
122
+ }
123
+
124
+ setExpanded(expanded ) {
125
+ this.clear();
126
+ this.addChild(new Text(theme.fg("customMessageLabel", theme.bold("[compaction]")), 0, 0));
127
+ this.addChild(new Spacer(1));
128
+ this.addChild(
129
+ expanded
130
+ ? new Markdown(this.#summary, 0, 0, getMarkdownTheme(), {
131
+ color: (text ) => theme.fg("customMessageText", text),
132
+ })
133
+ : new Text(
134
+ theme.fg("customMessageText", "Earlier context summarized (") +
135
+ theme.fg("dim", keyText("app.tools.expand")) +
136
+ theme.fg("customMessageText", " to expand)"),
137
+ 0,
138
+ 0,
139
+ ),
140
+ );
141
+ }
142
+ }
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+ class DurableTui {
154
+ static #renderers = createAllToolRenderers();
155
+ #ui ;
156
+ #chat = new Container();
157
+ #tasks = new Container();
158
+ #queue = new Container();
159
+ #notices = new Container();
160
+ #footer = new Container();
161
+ #footerStats = new Text("", 1, 0);
162
+ #footerHints = new Text("", 1, 0);
163
+ #editorContainer = new Container();
164
+ #editor ;
165
+ #cwd ;
166
+ /** The newest card per call ID; provider call IDs may repeat across turns. */
167
+ #tools = new Map ();
168
+ /** Every card shown, also older ones whose call ID a later turn reused. */
169
+ #cards = [];
170
+ /** Call IDs whose cards the streaming answer created; its entry takes them over. */
171
+ #streamingCalls = new Set ();
172
+ #summaries = [];
173
+ /** Tool output and summaries shown in full; toggled like pi. */
174
+ #expanded = false;
175
+ #renderedEntryIds = [];
176
+ #streaming ;
177
+ #indicator ;
178
+ #statusText = "";
179
+ /** Set when the transcript was rebuilt: the next render repaints the screen and shows the end. */
180
+ #rebuilt = false;
181
+ #transcript ;
182
+
183
+ constructor(cwd , handlers ) {
184
+ this.#cwd = cwd;
185
+ this.#ui = new TuiAltScreen(new ProcessTerminal(), false, getAgentDir());
186
+ const keybindings = KeybindingsManager.create();
187
+ setKeybindings(keybindings);
188
+ this.#editor = new CustomEditor(this.#ui, getEditorTheme(), keybindings, {
189
+ paddingX: 1,
190
+ embedWorkingStatus: true,
191
+ });
192
+ this.#editor.onSubmit = handlers.submit;
193
+ this.#editor.onEscape = handlers.abort;
194
+ this.#editor.onCtrlD = handlers.exit;
195
+ this.#editor.onAction("app.clear", handlers.exit);
196
+ this.#editor.onAction("app.model.select", handlers.selectModel);
197
+ this.#editor.onAction("app.thinking.cycle", handlers.cycleThinking);
198
+ this.#editor.onAction("app.tools.expand", () => {
199
+ this.#expanded = !this.#expanded;
200
+ for (const component of [...this.#cards, ...this.#summaries]) component.setExpanded(this.#expanded);
201
+ this.#ui.requestRender();
202
+ });
203
+ this.#editor.onAction("app.message.followUp", () => {
204
+ const text = this.#editor.getText().trim();
205
+ if (!text) return;
206
+ this.#editor.setText("");
207
+ handlers.followUp(text);
208
+ });
209
+
210
+ this.#editorContainer.addChild(this.#editor);
211
+ this.#footer.addChild(this.#footerStats);
212
+ this.#footer.addChild(this.#footerHints);
213
+ // One empty line between the transcript and everything below it.
214
+ const content = new Container();
215
+ content.addChild(this.#chat);
216
+ content.addChild(new Spacer(1));
217
+ const transcript = new ScrollView(content, { follow: "end", primary: true, overscroll: "chain" });
218
+ this.#transcript = transcript;
219
+ const dock = new VStack([
220
+ { component: this.#tasks, shrink: 1, minSize: 0 },
221
+ { component: this.#queue, shrink: 1, minSize: 0 },
222
+ { component: this.#notices, shrink: 1, minSize: 0 },
223
+ { component: this.#editorContainer, shrink: 1, minSize: 3 },
224
+ { component: this.#footer, shrink: 1, minSize: 0 },
225
+ ]);
226
+ for (const component of [
227
+ this.#chat,
228
+ this.#tasks,
229
+ this.#queue,
230
+ this.#notices,
231
+ this.#editorContainer,
232
+ this.#footer,
233
+ ]) {
234
+ this.#ui.addChild(component);
235
+ }
236
+ this.#ui.setLayoutRoot(
237
+ new VStack([
238
+ { component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
239
+ { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
240
+ ]),
241
+ );
242
+ this.#ui.setFocus(this.#editor);
243
+ }
244
+
245
+ get ui() {
246
+ return this.#ui;
247
+ }
248
+
249
+ start() {
250
+ this.#ui.start();
251
+ }
252
+
253
+ stop() {
254
+ this.#discardTools();
255
+ this.#indicator?.dispose();
256
+ this.#ui.stop();
257
+ }
258
+
259
+ /** Finish every card: a running bash card keeps a timer until it gets a final result. */
260
+ #discardTools() {
261
+ for (const component of this.#cards) component.updateResult({ content: [], isError: false }, false);
262
+ this.#cards.length = 0;
263
+ this.#tools.clear();
264
+ this.#streamingCalls.clear();
265
+ }
266
+
267
+ mount(component ) {
268
+ this.#editorContainer.clear();
269
+ this.#editorContainer.addChild(component);
270
+ this.#ui.setFocus(component);
271
+ this.#ui.requestRender();
272
+ }
273
+
274
+ restoreEditor() {
275
+ this.mount(this.#editor);
276
+ }
277
+
278
+ apply(view ) {
279
+ const live = (view.conversation.docs["pi.live"] ?? {}) ;
280
+ this.#syncTranscript(view.conversation.entries);
281
+ const message = live.generation?.message ;
282
+ // A partial without its entry was dropped, for example by a retry: render the transcript again.
283
+ if (message === undefined && this.#streaming !== undefined) this.#rebuild(view.conversation.entries);
284
+ if (message !== undefined) this.#syncStreaming(message);
285
+ for (const slot of live.tools ?? []) {
286
+ if (slot.status === "pending") continue;
287
+ const component = this.#tool(slot.name, slot.callId);
288
+ component.setArgsComplete();
289
+ if (slot.status !== "running") continue;
290
+ component.markExecutionStarted();
291
+ const child = (slot.details )?.conversationId;
292
+ if (slot.output === undefined && child !== undefined) {
293
+ const text = `Subagent ${child} is working. /agents switches to it.`;
294
+ component.updateResult({ content: [{ type: "text", text }], details: slot.details, isError: false }, true);
295
+ } else if (slot.output !== undefined) {
296
+ component.updateResult(
297
+ { content: [{ type: "text", text: slot.output }], details: slot.details, isError: false },
298
+ true,
299
+ );
300
+ }
301
+ }
302
+ this.#syncTasks(view.tasks);
303
+ this.#syncQueue((view.conversation.docs["pi.inbox"] ?? { items: [] }) );
304
+ this.#syncNotices(view);
305
+ this.#editor.borderColor = theme.getThinkingBorderColor(agentOf(view.conversation).thinkingLevel ?? "off");
306
+ this.#syncStatus(live);
307
+ this.#syncFooter(view);
308
+ if (this.#rebuilt) this.#transcript.scrollToEnd();
309
+ this.#ui.requestRender(this.#rebuilt);
310
+ this.#rebuilt = false;
311
+ }
312
+
313
+ #syncTasks(graph ) {
314
+ this.#tasks.clear();
315
+ if (graph === undefined) return;
316
+ const nodes = Object.values(graph.tasks);
317
+ const lines = [theme.fg("accent", `Tasks (${nodes.length} live, /tasks to hide)`)];
318
+ // A conversation-owned task sits under the task that owns its conversation, when that task is live.
319
+ const owned = new Set(nodes.flatMap((node) => node.conversations));
320
+ const children = (node ) =>
321
+ nodes.filter(
322
+ (candidate) =>
323
+ candidate.owner === node.id ||
324
+ (candidate.owner === undefined && node.conversations.includes(candidate.conversationId)),
325
+ );
326
+ const visit = (node , depth ) => {
327
+ lines.push(`${" ".repeat(depth + 1)}${describeTask(node)}`);
328
+ for (const child of children(node)) visit(child, depth + 1);
329
+ };
330
+ for (const node of nodes) {
331
+ if (node.owner === undefined && !owned.has(node.conversationId)) visit(node, 0);
332
+ }
333
+ for (const line of lines) this.#tasks.addChild(new TruncatedText(theme.fg("muted", line), 1, 0));
334
+ }
335
+
336
+ #syncQueue(inbox ) {
337
+ this.#queue.clear();
338
+ for (const item of inbox.items) {
339
+ const text =
340
+ item.mode === "write" ? `<${String(item.entry.kind)}>` : userText(item.content );
341
+ this.#queue.addChild(new TruncatedText(theme.fg("muted", `[${item.mode}] ${text}`), 1, 0));
342
+ }
343
+ }
344
+
345
+ #syncNotices(view ) {
346
+ this.#notices.clear();
347
+ for (const item of view.notices.slice(-4)) {
348
+ const color = item.level === "error" ? "error" : item.level === "warning" ? "warning" : "muted";
349
+ this.#notices.addChild(new TruncatedText(theme.fg(color, item.message), 1, 0));
350
+ }
351
+ }
352
+
353
+ #syncStatus(live ) {
354
+ const generation = live.generation;
355
+ const compaction = live.compactions?.[0];
356
+ const runningTool = live.tools?.find((slot) => slot.status === "running");
357
+ let text = "";
358
+ if (generation?.retry !== undefined) {
359
+ text = `Retrying (attempt ${generation.attempt + 1}): ${generation.retry.error}`;
360
+ } else if (generation?.deferred !== undefined) text = "Waiting for deferred response...";
361
+ else if (compaction !== undefined) {
362
+ text = compaction.retry
363
+ ? `Retrying ${compaction.reason} compaction (attempt ${compaction.attempt + 1})...`
364
+ : `Compacting (${compaction.reason})...`;
365
+ } else if (runningTool !== undefined) text = `Running ${runningTool.name}... (esc to abort)`;
366
+ else if (live.run !== undefined) text = "Working... (esc to abort)";
367
+ if (text === this.#statusText) return;
368
+ this.#statusText = text;
369
+ this.#indicator?.dispose();
370
+ this.#indicator = text
371
+ ? new WorkingStatusIndicator(this.#ui, text, undefined, (part) => this.#editor.borderColor(part))
372
+ : undefined;
373
+ this.#editor.setWorkingStatusIndicator(this.#indicator);
374
+ }
375
+
376
+ #syncFooter(view ) {
377
+ const agent = agentOf(view.conversation);
378
+ const usage = totalUsage((view.conversation.docs["pi.usage"] ?? { models: {}, tools: {} }) );
379
+ const stats = [];
380
+ if (usage.input) stats.push(`↑${formatTokens(usage.input)}`);
381
+ if (usage.output) stats.push(`↓${formatTokens(usage.output)}`);
382
+ if (usage.cacheRead) stats.push(`R${formatTokens(usage.cacheRead)}`);
383
+ if (usage.cacheWrite) stats.push(`W${formatTokens(usage.cacheWrite)}`);
384
+ stats.push(`$${usage.cost.total.toFixed(3)}`);
385
+ const contextWindow =
386
+ view.models.find((model) => model.provider === agent.model?.provider && model.modelId === agent.model.modelId)
387
+ ?.contextWindow ?? 0;
388
+ if (contextWindow > 0) {
389
+ const tokens = contextTokens(view.conversation.entries);
390
+ const percent = tokens === undefined ? undefined : (tokens / contextWindow) * 100;
391
+ const text = `${percent === undefined ? "?" : percent.toFixed(1)}%/${formatTokens(contextWindow)}`;
392
+ stats.push(percent !== undefined && percent > 90 ? theme.fg("error", text) : text);
393
+ }
394
+ this.#footerStats.setText(theme.fg("dim", `${stats.join(" ")} ${view.session.cwd}`));
395
+ const model = agent.model === undefined ? "no model" : `${agent.model.provider}/${agent.model.modelId}`;
396
+ const shown = view.conversations.find((candidate) => candidate.id === view.conversation.conversation.id);
397
+ const label = shown?.label ?? `conversation ${view.conversation.conversation.id}`;
398
+ this.#footerHints.setText(
399
+ `${theme.fg(label === "main" ? "dim" : "accent", label)}${theme.fg(
400
+ "dim",
401
+ ` · ${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`,
402
+ )}`,
403
+ );
404
+ }
405
+
406
+ #syncTranscript(entries ) {
407
+ // Compaction and resets replace the head of the active transcript.
408
+ if (this.#renderedEntryIds.some((id, index) => entries[index]?.id !== id)) this.#rebuild(entries);
409
+ for (const entry of entries.slice(this.#renderedEntryIds.length)) {
410
+ this.#addEntry(entry);
411
+ this.#renderedEntryIds.push(entry.id);
412
+ }
413
+ }
414
+
415
+ #rebuild(entries ) {
416
+ this.#chat.clear();
417
+ this.#discardTools();
418
+ this.#summaries.length = 0;
419
+ this.#rebuilt = true;
420
+ this.#renderedEntryIds = [];
421
+ this.#streaming = undefined;
422
+ for (const entry of entries) {
423
+ this.#addEntry(entry);
424
+ this.#renderedEntryIds.push(entry.id);
425
+ }
426
+ }
427
+
428
+ #addEntry(entry ) {
429
+ const message = entry.model?.[0];
430
+ if (entry.kind === "pi.user" && message?.role === "user") {
431
+ this.#chat.addChild(new Spacer(1));
432
+ this.#chat.addChild(new UserMessageComponent(userText(message.content)));
433
+ } else if (entry.kind === "pi.assistant" && message?.role === "assistant") {
434
+ const component = this.#streaming ?? new AssistantMessageComponent();
435
+ if (this.#streaming === undefined) this.#chat.addChild(component);
436
+ this.#streaming = undefined;
437
+ component.updateContent(message, false);
438
+ // Only a tool-calling answer runs its calls; an aborted, failed, or truncated one never does.
439
+ const ran = message.stopReason === "toolUse";
440
+ for (const content of message.content) {
441
+ if (content.type !== "toolCall") continue;
442
+ const streamed = this.#streamingCalls.has(content.id);
443
+ // Only cards the stream already showed are kept for calls that never run.
444
+ if (!ran && !streamed) continue;
445
+ const card = this.#tool(content.name, content.id, content.arguments, !streamed);
446
+ card.setArgsComplete();
447
+ if (!ran) {
448
+ const text = "Not run: the answer was interrupted.";
449
+ card.updateResult({ content: [{ type: "text", text }], isError: true }, false);
450
+ }
451
+ }
452
+ this.#streamingCalls.clear();
453
+ } else if (entry.kind === "pi.tool-result" && message?.role === "toolResult") {
454
+ const result = message ;
455
+ this.#tool(result.toolName, result.toolCallId).updateResult(result);
456
+ } else if (entry.kind === "pi.compaction") {
457
+ const summary = new CompactionComponent(
458
+ message?.role === "user" ? userText(message.content) : "",
459
+ this.#expanded,
460
+ );
461
+ this.#summaries.push(summary);
462
+ this.#chat.addChild(new Spacer(1));
463
+ this.#chat.addChild(summary);
464
+ } else if (entry.kind === "pi.reset") this.#addText("[new context]");
465
+ }
466
+
467
+ #syncStreaming(message ) {
468
+ if (this.#streaming === undefined) {
469
+ this.#streaming = new AssistantMessageComponent();
470
+ this.#chat.addChild(this.#streaming);
471
+ }
472
+ this.#streaming.updateContent(message, true);
473
+ for (const content of message.content) {
474
+ if (content.type !== "toolCall") continue;
475
+ this.#tool(content.name, content.id, content.arguments, !this.#streamingCalls.has(content.id));
476
+ this.#streamingCalls.add(content.id);
477
+ }
478
+ }
479
+
480
+ #addText(text ) {
481
+ this.#chat.addChild(new Spacer(1));
482
+ this.#chat.addChild(new Text(theme.fg("muted", text), 1, 0));
483
+ }
484
+
485
+ /** The card of a call; `fresh` starts a new one for a call ID an earlier turn used. */
486
+ #tool(name , callId , args , fresh = false) {
487
+ const existing = fresh ? undefined : this.#tools.get(callId);
488
+ if (existing !== undefined) {
489
+ if (args !== undefined) existing.updateArgs(args);
490
+ return existing;
491
+ }
492
+ const component = new ToolExecutionComponent(
493
+ name,
494
+ callId,
495
+ args ?? {},
496
+ {},
497
+ DurableTui.#renderers[name],
498
+ this.#ui,
499
+ this.#cwd,
500
+ );
501
+ component.setExpanded(this.#expanded);
502
+ this.#chat.addChild(component);
503
+ this.#cards.push(component);
504
+ this.#tools.set(callId, component);
505
+ return component;
506
+ }
507
+ }
508
+
509
+ function describeTask(node ) {
510
+ const state = node.state;
511
+ const status =
512
+ state.status === "waiting"
513
+ ? `waiting on ${state.on.join(", ")}`
514
+ : state.status === "completing"
515
+ ? `completing (${state.outcome})`
516
+ : `${state.status} ${state.phase}`;
517
+ const flags = [node.background ? "background" : "", node.abortRequested ? "aborting" : ""].filter(Boolean);
518
+ const owned = node.conversations.length > 0 ? ` owns conversation ${node.conversations.join(", ")}` : "";
519
+ return `${node.kind} #${node.id}: ${status}${flags.length > 0 ? ` [${flags.join(", ")}]` : ""}${owned}`;
520
+ }
521
+
522
+ function userText(content ) {
523
+ if (typeof content === "string") return content;
524
+ return content
525
+ .filter((block) => block.type === "text")
526
+ .map((block) => block.text)
527
+ .join("");
528
+ }
529
+
530
+ function totalUsage(state ) {
531
+ const total = {
532
+ input: 0,
533
+ output: 0,
534
+ cacheRead: 0,
535
+ cacheWrite: 0,
536
+ totalTokens: 0,
537
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
538
+ };
539
+ for (const usage of [...Object.values(state.models), ...Object.values(state.tools)]) {
540
+ total.input += usage.input;
541
+ total.output += usage.output;
542
+ total.cacheRead += usage.cacheRead;
543
+ total.cacheWrite += usage.cacheWrite;
544
+ total.cost.total += usage.cost.total;
545
+ }
546
+ return total;
547
+ }
548
+
549
+ /** Context size from the newest successful answer after the newest compaction; unknown before one. */
550
+ function contextTokens(entries ) {
551
+ // Kept entries follow the summary in the view but are older than it; only later answers measure the new context.
552
+ const compacted = Math.max(0, ...entries.filter((entry) => entry.kind === "pi.compaction").map((entry) => entry.id));
553
+ for (const entry of [...entries].reverse()) {
554
+ const message = entry.model?.[0];
555
+ if (entry.id < compacted || entry.kind !== "pi.assistant" || message?.role !== "assistant") continue;
556
+ if (message.stopReason === "aborted" || message.stopReason === "error") continue;
557
+ const usage = message.usage;
558
+ return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
559
+ }
560
+ return undefined;
561
+ }
562
+
563
+ export async function runDurableTui(
564
+ source ,
565
+ controller ,
566
+ settings ,
567
+ ) {
568
+ setCapabilityOverrides(settings.getTerminalCapabilityOverrides());
569
+ // The system theme until the controller resolves the user's theme against the terminal's colors.
570
+ initTheme();
571
+ let exit = () => {};
572
+ const exited = new Promise ((resolve) => {
573
+ exit = resolve;
574
+ });
575
+ let view ;
576
+
577
+ const selectModel = () => {
578
+ const snapshot = source.current();
579
+ const current = agentOf(snapshot.conversation).model;
580
+ const isCurrent = (model ) =>
581
+ model.provider === current?.provider && model.modelId === current.modelId;
582
+ const items = [...snapshot.models]
583
+ .sort((left, right) => Number(isCurrent(right)) - Number(isCurrent(left)))
584
+ .map((model) => ({
585
+ value: `${model.provider}/${model.modelId}`,
586
+ label: model.modelId,
587
+ description: model.provider,
588
+ }));
589
+ const selector = new ListSelector(
590
+ "Select model:",
591
+ items,
592
+ (value) => {
593
+ view.restoreEditor();
594
+ const separator = value.indexOf("/");
595
+ void controller.setModel({ provider: value.slice(0, separator), modelId: value.slice(separator + 1) });
596
+ },
597
+ () => view.restoreEditor(),
598
+ );
599
+ view.mount(selector);
600
+ };
601
+
602
+ const selectConversation = () => {
603
+ const snapshot = source.current();
604
+ const items = [...snapshot.conversations].reverse().map((candidate) => ({
605
+ value: String(candidate.id),
606
+ label: candidate.label,
607
+ description: `${candidate.id === snapshot.conversation.conversation.id ? "(shown) " : ""}${candidate.title ?? ""}`,
608
+ }));
609
+ const selector = new ListSelector(
610
+ "Switch to:",
611
+ items,
612
+ (value) => {
613
+ view.restoreEditor();
614
+ void controller.switchConversation(Number(value) );
615
+ },
616
+ () => view.restoreEditor(),
617
+ );
618
+ view.mount(selector);
619
+ };
620
+
621
+ view = new DurableTui(source.current().session.cwd, {
622
+ submit: (text) => {
623
+ const trimmed = text.trim();
624
+ if (!trimmed) return;
625
+ if (trimmed === "/model") return selectModel();
626
+ if (trimmed === "/tasks") return void controller.toggleTasks();
627
+ if (trimmed === "/agents") return selectConversation();
628
+ if (trimmed === "/compact" || trimmed.startsWith("/compact ")) {
629
+ const instructions = trimmed.slice("/compact".length).trim();
630
+ return void controller.compact(instructions || undefined);
631
+ }
632
+ void controller.submit(trimmed, "steer");
633
+ },
634
+ followUp: (text) => void controller.submit(text, "followUp"),
635
+ abort: () => void controller.abort(),
636
+ exit,
637
+ selectModel,
638
+ cycleThinking: () => void controller.cycleThinking(),
639
+ });
640
+
641
+ // pi's theme handling: the theme setting (also light/dark pairs) resolved against the terminal's reported colors.
642
+ const themes = new InteractiveThemeController(view.ui, {
643
+ getSettingsManager: () => settings,
644
+ showError: (message) => console.error(message),
645
+ onChanged: () => view.ui.requestRender(),
646
+ });
647
+ const unsubscribe = source.subscribe(() => view.apply(source.current()));
648
+ view.start();
649
+ themes.applyFromSettings();
650
+ view.apply(source.current());
651
+ await exited;
652
+ unsubscribe();
653
+ themes.dispose();
654
+ view.stop();
655
+ }