@tt-a1i/openpi 0.3.1 → 0.5.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 (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Takeover UI for subagents (ported from v1, rendering from the synchronous
3
3
  * SubagentReadModel instead of live pi sessions):
4
- * - SubagentDashboard: full popup (overlay) listing all subagents.
5
- * - TakeoverView: full interactive view of one subagent with an input line
6
- * to steer/continue it.
4
+ * - SubagentDashboard: compact picker docked above the input, listing all subagents.
5
+ * - TakeoverView: full read-only view of one subagent; steering remains owned
6
+ * by the parent model through the subagent tools.
7
7
  */
8
8
 
9
9
  import type {
@@ -12,17 +12,15 @@ import type {
12
12
  Theme,
13
13
  } from "@earendil-works/pi-coding-agent";
14
14
  import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
15
- import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
+ import { AgentSessionPage } from "../../../shared/agent-session-page.ts";
17
+ import { hintLine, panelFrame } from "../../../shared/screen-chrome.ts";
16
18
  import { sanitizeTerminalText } from "../../../shared/terminal-text.ts";
17
19
  import { formatElapsed, type SubagentSnapshot } from "../domain.ts";
18
- import { formatContextUtilization } from "../format.ts";
20
+ import { formatContextUtilization } from "../../../shared/context-utilization.ts";
21
+ import { SPINNER_INTERVAL_MS, spinnerFrame } from "../../../shared/spinner.ts";
19
22
  import type { SubagentReadModel } from "../manager.ts";
20
- import {
21
- SPINNER_INTERVAL_MS,
22
- TranscriptRenderer,
23
- buildTranscriptLines,
24
- spinnerFrame,
25
- } from "./transcript.ts";
23
+ import { subagentTranscriptDocument } from "./transcript.ts";
26
24
 
27
25
  export function sanitizeSubagentDisplayLine(value: string) {
28
26
  return sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
@@ -38,58 +36,34 @@ function configuredKeys(
38
36
  /**
39
37
  * One spinner definition for the whole subagent UI: the dashboard glyph, the
40
38
  * takeover header, and the transcript's live tools must animate in step, so the
41
- * frames and their cadence live in `transcript.ts` and are imported here.
39
+ * frames and their cadence live in `shared/spinner.ts` and are imported here.
42
40
  */
43
41
  function statusGlyph(
44
42
  snap: SubagentSnapshot,
45
43
  theme: Theme,
46
44
  now = Date.now(),
45
+ selected = false,
47
46
  ): string {
47
+ // A selected row keeps its state glyph and borrows the accent tone, so the
48
+ // list never hides what is still running behind a selection marker.
49
+ const tone = (color: "warning" | "success" | "error") =>
50
+ selected ? ("accent" as const) : color;
48
51
  switch (snap.status) {
49
52
  case "running":
50
- return theme.fg("warning", spinnerFrame(now));
53
+ return theme.fg(tone("warning"), spinnerFrame(now));
51
54
  case "done":
52
- return theme.fg("success", "✓");
55
+ return theme.fg(tone("success"), "✓");
53
56
  case "error":
54
- return theme.fg("error", "✗");
57
+ return theme.fg(tone("error"), "✗");
55
58
  }
56
59
  }
57
60
 
58
- /**
59
- * Tool names come from the child's own tool-call events, so they are as
60
- * untrusted as their arguments and must be sanitized before reaching the
61
- * terminal: `truncateToWidth` only ignores escape sequences while measuring.
62
- */
63
- function runningActivity(snap: SubagentSnapshot) {
64
- if (snap.status !== "running") return "";
65
- const liveTool = snap.liveTools.at(-1);
66
- if (liveTool) {
67
- const name = sanitizeSubagentDisplayLine(liveTool.name);
68
- const args = truncateToWidth(
69
- sanitizeSubagentDisplayLine(liveTool.argsPreview ?? ""),
70
- 48,
71
- );
72
- return args ? `${name} · ${args}` : name;
73
- }
74
- for (const item of [...snap.transcript].reverse()) {
75
- if (item.kind === "toolResult") {
76
- return sanitizeSubagentDisplayLine(item.name);
77
- }
78
- if (item.kind !== "assistant") continue;
79
- const tool = [...item.parts]
80
- .reverse()
81
- .find((part) => part.type === "toolCall");
82
- if (tool?.type === "toolCall") {
83
- return sanitizeSubagentDisplayLine(tool.name);
84
- }
85
- }
86
- return "";
87
- }
88
-
89
61
  // --- Entry points --------------------------------------------------------------
90
62
 
91
63
  export interface TakeoverOptions {
92
64
  readonly badge?: string;
65
+ /** Captured from the parent UI when this child page opens. */
66
+ readonly toolsExpanded?: boolean;
93
67
  }
94
68
 
95
69
  export async function openSubagentTakeover(
@@ -99,12 +73,24 @@ export async function openSubagentTakeover(
99
73
  options?: TakeoverOptions,
100
74
  ) {
101
75
  if (!view.get(id)) return;
76
+ const takeoverOptions: TakeoverOptions = {
77
+ ...options,
78
+ toolsExpanded: ctx.ui.getToolsExpanded(),
79
+ };
102
80
  await ctx.ui.custom<null>(
103
81
  (tui, theme, keybindings, done) =>
104
- new TakeoverView(tui, theme, keybindings, id, view, done, options),
82
+ new TakeoverView(
83
+ tui,
84
+ theme,
85
+ keybindings,
86
+ id,
87
+ view,
88
+ done,
89
+ takeoverOptions,
90
+ ),
105
91
  {
106
92
  overlay: true,
107
- overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
93
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" },
108
94
  },
109
95
  );
110
96
  }
@@ -127,7 +113,14 @@ export async function openSubagentPicker(
127
113
  new SubagentDashboard(tui, theme, keybindings, view, selection, done),
128
114
  {
129
115
  overlay: true,
130
- overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
116
+ // Dock the picker just above the editor (editor + strip + footer ≈ 5
117
+ // rows) like a command palette, instead of covering the conversation.
118
+ overlayOptions: {
119
+ anchor: "bottom-center",
120
+ width: "100%",
121
+ maxHeight: "60%",
122
+ margin: { bottom: 5 },
123
+ },
131
124
  },
132
125
  );
133
126
 
@@ -139,7 +132,10 @@ export async function openSubagentPicker(
139
132
  }
140
133
  }
141
134
 
142
- // --- Dashboard (fullscreen overlay) ----------------------------------------------
135
+ // --- Dashboard (picker docked above the input) ---------------------------------
136
+
137
+ /** A picker is a glance, not a workspace: cap the list window and scroll. */
138
+ const MAX_PICKER_ROWS = 10;
143
139
 
144
140
  export interface DashboardSelection {
145
141
  id?: string;
@@ -258,24 +254,6 @@ export class SubagentDashboard implements Component {
258
254
  }
259
255
  }
260
256
 
261
- private pad(text: string, width: number): string {
262
- const truncated = truncateToWidth(text, width);
263
- return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
264
- }
265
-
266
- private borderSegment(width: number, title: string): string {
267
- const theme = this.theme;
268
- const label = title
269
- ? ` ${truncateToWidth(title, Math.max(0, width - 3))} `
270
- : "";
271
- const labelWidth = visibleWidth(label);
272
- return (
273
- theme.fg("border", "─") +
274
- (label ? theme.fg("text", label) : "") +
275
- theme.fg("border", "─".repeat(Math.max(0, width - 1 - labelWidth)))
276
- );
277
- }
278
-
279
257
  render(width: number): string[] {
280
258
  const theme = this.theme;
281
259
  const subs = this.subs();
@@ -283,13 +261,14 @@ export class SubagentDashboard implements Component {
283
261
 
284
262
  // One timestamp per frame so every row's spinner shows the same frame.
285
263
  const now = Date.now();
264
+ // Docked above the editor, the panel borrows conversation space: cap the
265
+ // list window and scroll instead of growing toward the top of the screen.
286
266
  const rows = this.tui.terminal.rows || 30;
287
- const maxBodyHeight = Math.max(1, rows - 5);
267
+ const maxBodyHeight = Math.min(Math.max(1, rows - 5), MAX_PICKER_ROWS);
288
268
  const bodyHeight =
289
269
  subs.length > maxBodyHeight ? maxBodyHeight : Math.max(1, subs.length);
290
270
  const innerWidth = Math.max(0, width - 2);
291
271
 
292
- const lines: string[] = [];
293
272
  const running = subs.filter((snap) => snap.status === "running").length;
294
273
  const done = subs.filter((snap) => snap.status === "done").length;
295
274
  const failed = subs.filter((snap) => snap.status === "error").length;
@@ -301,37 +280,32 @@ export class SubagentDashboard implements Component {
301
280
  ]
302
281
  .filter(Boolean)
303
282
  .join(" · ") || "no agents";
304
- lines.push(
305
- theme.fg("border", "╭") +
306
- this.borderSegment(innerWidth, `Subagents · ${summary}`) +
307
- theme.fg("border", "╮"),
308
- );
309
-
310
- const divider = theme.fg("border", "│");
311
283
  const rowLines = this.renderRows(subs, innerWidth, bodyHeight, now);
312
- for (const row of rowLines) {
313
- lines.push(divider + this.pad(row, innerWidth) + divider);
314
- }
315
-
316
- // Bottom border
317
- lines.push(
318
- theme.fg("border", "╰") +
319
- theme.fg("border", "─".repeat(innerWidth)) +
320
- theme.fg("border", "╯"),
321
- );
322
-
323
- // Hints
324
- lines.push(
325
- truncateToWidth(
326
- theme.fg(
327
- "dim",
328
- ` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} take over · x abort · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`,
329
- ),
284
+ const keys = (binding: Parameters<KeybindingsManager["getKeys"]>[0]) =>
285
+ configuredKeys(this.keybindings, binding);
286
+ // Shared chrome, so this panel and its hints read the same as /ps and
287
+ // /workflows. The frame is padded to the rows it was given, which keeps
288
+ // this view's content-fit height rather than reintroducing a fixed one.
289
+ return [
290
+ // One empty row of air between the conversation and the docked panel.
291
+ "",
292
+ ...panelFrame(theme, {
293
+ label: `Subagents · ${summary}`,
294
+ rows: rowLines,
295
+ width,
296
+ height: rowLines.length + 2,
297
+ }),
298
+ hintLine(
299
+ theme,
300
+ [
301
+ [`${keys("tui.select.up")}/${keys("tui.select.down")}/jk`, "select"],
302
+ [keys("tui.select.confirm"), "take over"],
303
+ ["x", "abort"],
304
+ [keys("tui.select.cancel"), "close"],
305
+ ],
330
306
  width,
331
307
  ),
332
- );
333
-
334
- return lines;
308
+ ];
335
309
  }
336
310
 
337
311
  private renderRows(
@@ -362,13 +336,16 @@ export class SubagentDashboard implements Component {
362
336
  const index = start + i;
363
337
  const isSelected = index === this.selection.index;
364
338
 
365
- const marker = isSelected ? theme.fg("accent", "❯") : " ";
339
+ // One glyph column: a selected row tints its status glyph with the
340
+ // accent tone instead of stacking a second marker. The live tool name
341
+ // is deliberately absent: it flickers with every tool call and says
342
+ // nothing the user acts on.
343
+ const glyph = statusGlyph(snap, theme, now, isSelected);
366
344
  const safeTitle = sanitizeSubagentDisplayLine(snap.title) || snap.id;
367
345
  const title = isSelected
368
346
  ? theme.fg("accent", safeTitle)
369
347
  : theme.fg("text", safeTitle);
370
- const activity = runningActivity(snap);
371
- const prefix = ` ${marker} ${statusGlyph(snap, theme, now)} `;
348
+ const prefix = ` ${glyph} `;
372
349
 
373
350
  const utilization = formatContextUtilization(snap.usage);
374
351
  const metadata = [
@@ -392,20 +369,9 @@ export class SubagentDashboard implements Component {
392
369
  const right = metadata.join(dot);
393
370
  const rightWidth = visibleWidth(right);
394
371
  const leftMax = Math.max(0, width - rightWidth - (right ? 2 : 0));
395
- const activityLabel = activity ? theme.fg("muted", ` · ${activity}`) : "";
396
372
  const left =
397
373
  prefix +
398
- truncateToWidth(
399
- title,
400
- Math.max(
401
- 0,
402
- leftMax - visibleWidth(prefix) - visibleWidth(activityLabel),
403
- ),
404
- ) +
405
- truncateToWidth(
406
- activityLabel,
407
- Math.max(0, leftMax - visibleWidth(prefix)),
408
- );
374
+ truncateToWidth(title, Math.max(0, leftMax - visibleWidth(prefix)));
409
375
  const gap = right
410
376
  ? Math.max(1, width - visibleWidth(left) - rightWidth)
411
377
  : 0;
@@ -428,33 +394,18 @@ export class SubagentDashboard implements Component {
428
394
 
429
395
  // --- Takeover view ------------------------------------------------------------
430
396
 
431
- const TRANSCRIPT_SCROLL_STEP = 6;
432
-
433
397
  export class TakeoverView implements Component, Focusable {
434
- private tui: TUI;
435
- private theme: Theme;
436
- private keybindings: KeybindingsManager;
437
- private id: string;
438
- private view: SubagentReadModel;
439
- private done: (value: null) => void;
440
- private options?: TakeoverOptions;
441
-
442
- private input = new Input();
443
- private transcriptRenderer = new TranscriptRenderer();
444
- /** Scroll offset in lines from the bottom of the transcript. 0 = pinned to bottom. */
445
- private scrollOffset = 0;
398
+ private page: AgentSessionPage;
446
399
  private unsubscribe: () => void;
447
400
  private renderTimer?: ReturnType<typeof setTimeout>;
448
401
  private ticker?: ReturnType<typeof setInterval>;
449
402
  private closed = false;
450
403
 
451
- private _focused = false;
452
- get focused(): boolean {
453
- return this._focused;
404
+ get focused() {
405
+ return this.page.focused;
454
406
  }
455
407
  set focused(value: boolean) {
456
- this._focused = value;
457
- this.input.focused = value;
408
+ this.page.focused = value;
458
409
  }
459
410
 
460
411
  constructor(
@@ -466,46 +417,58 @@ export class TakeoverView implements Component, Focusable {
466
417
  done: (value: null) => void,
467
418
  options?: TakeoverOptions,
468
419
  ) {
469
- this.tui = tui;
470
- this.theme = theme;
471
- this.keybindings = keybindings;
472
- this.id = id;
473
- this.view = view;
474
- this.done = done;
475
- this.options = options;
420
+ this.page = new AgentSessionPage(
421
+ tui,
422
+ theme,
423
+ keybindings,
424
+ {
425
+ getState: () => {
426
+ const snap = view.get(id);
427
+ if (!snap) return undefined;
428
+ return {
429
+ id: snap.id,
430
+ title: snap.title,
431
+ status: snap.status,
432
+ document: subagentTranscriptDocument(
433
+ snap,
434
+ view.getToolRenderer?.(id),
435
+ ),
436
+ metadata: [
437
+ options?.badge,
438
+ snap.meta.modelLabel,
439
+ formatContextUtilization(snap.usage),
440
+ formatElapsed(snap),
441
+ ],
442
+ errorText: snap.errorText,
443
+ };
444
+ },
445
+ close: () => this.close(done),
446
+ abort: () => view.requestAbort(id),
447
+ },
448
+ { toolsExpanded: options?.toolsExpanded },
449
+ );
476
450
  this.unsubscribe = view.subscribeTo(id, () => {
477
- this.refreshTicker();
478
- this.scheduleRender();
451
+ this.refreshTicker(view.get(id), tui);
452
+ this.scheduleRender(tui);
479
453
  });
480
- this.refreshTicker();
481
- this.input.onSubmit = (value: string) => {
482
- const text = value.trim();
483
- if (!text) return;
484
- this.input.setValue("");
485
- this.view.requestSend(this.id, text);
486
- this.scrollOffset = 0;
487
- this.tui.requestRender();
488
- };
454
+ this.refreshTicker(view.get(id), tui);
489
455
  }
490
456
 
491
- private snap(): SubagentSnapshot | undefined {
492
- return this.view.get(this.id);
493
- }
494
-
495
- private refreshTicker() {
496
- const interval =
497
- this.snap()?.status === "running" ? SPINNER_INTERVAL_MS : 1000;
457
+ private refreshTicker(snap: SubagentSnapshot | undefined, tui: TUI) {
498
458
  if (this.ticker) clearInterval(this.ticker);
499
- this.ticker = setInterval(() => this.tui.requestRender(), interval);
459
+ this.ticker = undefined;
460
+ if (snap?.status === "running") {
461
+ this.ticker = setInterval(() => tui.requestRender(), SPINNER_INTERVAL_MS);
462
+ }
500
463
  }
501
464
 
502
- private scheduleRender() {
465
+ private scheduleRender(tui: TUI) {
503
466
  if (this.renderTimer) return;
504
467
  // Streaming can emit an event per token. Limit terminal repaints so this
505
468
  // view cannot starve input handling or make the child look frozen.
506
469
  this.renderTimer = setTimeout(() => {
507
470
  this.renderTimer = undefined;
508
- if (!this.closed) this.tui.requestRender();
471
+ if (!this.closed) tui.requestRender();
509
472
  }, 50);
510
473
  }
511
474
 
@@ -519,8 +482,8 @@ export class TakeoverView implements Component, Focusable {
519
482
  return true;
520
483
  }
521
484
 
522
- private close() {
523
- if (this.cleanup()) this.done(null);
485
+ private close(done: (value: null) => void) {
486
+ if (this.cleanup()) done(null);
524
487
  }
525
488
 
526
489
  dispose(): void {
@@ -528,186 +491,14 @@ export class TakeoverView implements Component, Focusable {
528
491
  }
529
492
 
530
493
  handleInput(data: string): void {
531
- if (this.keybindings.matches(data, "app.clear")) {
532
- const snap = this.snap();
533
- if (snap?.status === "running") this.view.requestAbort(this.id);
534
- return;
535
- }
536
- if (
537
- this.keybindings.matches(data, "app.interrupt") ||
538
- this.keybindings.matches(data, "tui.select.cancel")
539
- ) {
540
- this.close();
541
- return;
542
- }
543
- if (this.keybindings.matches(data, "tui.editor.cursorUp")) {
544
- this.scrollOffset += TRANSCRIPT_SCROLL_STEP;
545
- this.tui.requestRender();
546
- return;
547
- }
548
- if (this.keybindings.matches(data, "tui.editor.cursorDown")) {
549
- this.scrollOffset = Math.max(
550
- 0,
551
- this.scrollOffset - TRANSCRIPT_SCROLL_STEP,
552
- );
553
- this.tui.requestRender();
554
- return;
555
- }
556
- if (this.keybindings.matches(data, "tui.editor.pageUp")) {
557
- this.scrollOffset += this.viewportHeight();
558
- this.tui.requestRender();
559
- return;
560
- }
561
- if (this.keybindings.matches(data, "tui.editor.pageDown")) {
562
- this.scrollOffset = Math.max(
563
- 0,
564
- this.scrollOffset - this.viewportHeight(),
565
- );
566
- this.tui.requestRender();
567
- return;
568
- }
569
- this.input.handleInput(data);
570
- this.tui.requestRender();
571
- }
572
-
573
- private viewportHeight(): number {
574
- const rows = this.tui.terminal.rows || 30;
575
- // Top rule, transcript rule, input, key hints, and bottom rule are five
576
- // chrome rows. The overlay leaves Pi's final footer row visible.
577
- return Math.max(1, rows - 6);
578
- }
579
-
580
- private rule(width: number, left = "", right = "") {
581
- const fill = "─";
582
- const available = Math.max(1, width);
583
- const leftWidth = visibleWidth(left);
584
- const rightWidth = visibleWidth(right);
585
- if (!right || leftWidth + rightWidth + 2 > available) {
586
- return truncateToWidth(
587
- left + fill.repeat(Math.max(0, available - leftWidth)),
588
- available,
589
- );
590
- }
591
- return (
592
- left +
593
- fill.repeat(Math.max(1, available - leftWidth - rightWidth)) +
594
- right
595
- );
494
+ this.page.handleInput(data);
596
495
  }
597
496
 
598
497
  render(width: number): string[] {
599
- const theme = this.theme;
600
- const now = Date.now();
601
- const lines: string[] = [];
602
- const snap = this.snap();
603
-
604
- if (!snap) {
605
- const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
606
- lines.push(
607
- border,
608
- theme.fg("dim", `${this.id} is no longer tracked`),
609
- border,
610
- );
611
- return lines;
612
- }
613
-
614
- const title = sanitizeSubagentDisplayLine(snap.title) || snap.id;
615
- const headerLeft =
616
- theme.fg("borderAccent", "─ ") +
617
- statusGlyph(snap, theme, now) +
618
- " " +
619
- theme.fg("accent", theme.bold(title)) +
620
- theme.fg("borderAccent", " ");
621
- const utilization = formatContextUtilization(snap.usage);
622
- const metadata = [
623
- ...(this.options?.badge
624
- ? [theme.fg("muted", sanitizeSubagentDisplayLine(this.options.badge))]
625
- : []),
626
- theme.fg(
627
- "muted",
628
- sanitizeSubagentDisplayLine(snap.meta.modelLabel ?? "?") || "?",
629
- ),
630
- ...(utilization ? [theme.fg("muted", utilization)] : []),
631
- theme.fg("muted", formatElapsed(snap)),
632
- ];
633
- const dot = theme.fg("dim", " · ");
634
- while (
635
- metadata.length > 1 &&
636
- visibleWidth(headerLeft) + visibleWidth(metadata.join(dot)) + 2 > width
637
- ) {
638
- metadata.shift();
639
- }
640
- lines.push(
641
- this.rule(
642
- width,
643
- truncateToWidth(
644
- headerLeft,
645
- Math.max(1, width - visibleWidth(metadata.join(dot)) - 2),
646
- ),
647
- metadata.join(dot),
648
- ),
649
- );
650
-
651
- // Fixed-height transcript viewport. Errors consume a row, but scroll state
652
- // is represented by the following rule so its height never changes.
653
- // `now` is shared with the header glyph so both spinners show one frame.
654
- const transcript = buildTranscriptLines(
655
- snap,
656
- width,
657
- theme,
658
- this.transcriptRenderer,
659
- { now },
660
- );
661
- const viewport = this.viewportHeight();
662
- const errorRows = snap.errorText ? 1 : 0;
663
- const transcriptCapacity = Math.max(1, viewport - errorRows);
664
- const maxOffset = Math.max(0, transcript.length - transcriptCapacity);
665
- if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset;
666
-
667
- const body: string[] = [];
668
- if (snap.errorText) {
669
- body.push(
670
- truncateToWidth(
671
- theme.fg(
672
- "error",
673
- `error: ${sanitizeSubagentDisplayLine(snap.errorText)}`,
674
- ),
675
- width,
676
- ),
677
- );
678
- }
679
- const end = transcript.length - this.scrollOffset;
680
- const visible = transcript.slice(
681
- Math.max(0, end - Math.max(1, viewport - body.length)),
682
- end,
683
- );
684
- if (visible.length === 0) body.push(theme.fg("dim", "waiting for output…"));
685
- else body.push(...visible);
686
- while (body.length < viewport) body.push("");
687
- lines.push(...body.slice(0, viewport));
688
-
689
- lines.push(
690
- this.rule(
691
- width,
692
- theme.fg("borderAccent", "─"),
693
- this.scrollOffset > 0 ? theme.fg("dim", `↓ ${this.scrollOffset}`) : "",
694
- ),
695
- );
696
- lines.push(...this.input.render(width));
697
- const hints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`;
698
- const compactHints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll`;
699
- lines.push(
700
- truncateToWidth(
701
- theme.fg("dim", visibleWidth(hints) <= width ? hints : compactHints),
702
- width,
703
- ),
704
- );
705
- lines.push(this.rule(width, theme.fg("borderAccent", "─")));
706
- return lines;
498
+ return this.page.render(width);
707
499
  }
708
500
 
709
501
  invalidate(): void {
710
- this.input.invalidate();
711
- this.transcriptRenderer.invalidate();
502
+ this.page.invalidate();
712
503
  }
713
504
  }