@tt-a1i/openpi 0.4.0 → 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 (109) hide show
  1. package/README.md +102 -40
  2. package/SETUP.md +22 -6
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/background-terminals/index.ts +30 -2
  6. package/extensions/background-terminals/src/domain.ts +2 -0
  7. package/extensions/background-terminals/src/manager.ts +486 -106
  8. package/extensions/background-terminals/src/output.ts +33 -0
  9. package/extensions/background-terminals/src/prompt.ts +13 -5
  10. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  11. package/extensions/clear-context/index.ts +83 -0
  12. package/extensions/context-pivot/index.ts +16 -6
  13. package/extensions/cron/schedule.ts +7 -1
  14. package/extensions/file-mutation-display/render.ts +17 -257
  15. package/extensions/file-search/src/binaries.ts +57 -41
  16. package/extensions/git-read/index.ts +1 -3
  17. package/extensions/model-info/index.ts +21 -33
  18. package/extensions/model-info/session-metrics.ts +96 -0
  19. package/extensions/plan-mode/bash-policy.ts +54 -9
  20. package/extensions/plan-mode/index.ts +7 -2
  21. package/extensions/post-edit/index.ts +16 -6
  22. package/extensions/sessions/git-stats.ts +258 -72
  23. package/extensions/sessions/index.ts +153 -86
  24. package/extensions/sessions/preview-cache.ts +104 -0
  25. package/extensions/sessions/preview-loader.ts +856 -0
  26. package/extensions/sessions/sessions.ts +43 -4
  27. package/extensions/setup/index.ts +123 -127
  28. package/extensions/shared/activity-status.ts +30 -0
  29. package/extensions/shared/agent-session-page.ts +319 -0
  30. package/extensions/shared/agent-tool-renderer.ts +218 -0
  31. package/extensions/shared/agent-transcript.ts +524 -0
  32. package/extensions/shared/capability-intent.ts +1 -1
  33. package/extensions/shared/child-session.ts +437 -21
  34. package/extensions/shared/result-delivery.ts +34 -0
  35. package/extensions/shared/setup-config.ts +73 -33
  36. package/extensions/shared/setup-episode-state.ts +1 -1
  37. package/extensions/shared/terminal-text.ts +110 -23
  38. package/extensions/shared/text-projection.ts +72 -15
  39. package/extensions/shared/tool-activity.ts +382 -0
  40. package/extensions/shared/tool-surface.ts +29 -2
  41. package/extensions/shared/transcript-viewport.ts +46 -0
  42. package/extensions/shared/web-observer-registry.ts +390 -0
  43. package/extensions/shared/worktree.ts +11 -0
  44. package/extensions/subagents/index.ts +270 -59
  45. package/extensions/subagents/navigation.ts +34 -5
  46. package/extensions/subagents/src/backend.ts +12 -1
  47. package/extensions/subagents/src/backends/pi.ts +375 -66
  48. package/extensions/subagents/src/domain.ts +5 -0
  49. package/extensions/subagents/src/manager.ts +34 -2
  50. package/extensions/subagents/src/prompt.ts +32 -4
  51. package/extensions/subagents/src/result-artifact.ts +4 -0
  52. package/extensions/subagents/src/result-delivery.ts +7 -1
  53. package/extensions/subagents/src/runtime.ts +15 -1
  54. package/extensions/subagents/src/ui/takeover.ts +73 -257
  55. package/extensions/subagents/src/ui/transcript.ts +38 -535
  56. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  57. package/extensions/suggestions/src/ui.ts +10 -4
  58. package/extensions/tasks/index.ts +0 -3
  59. package/extensions/ui-customization/footer.ts +0 -40
  60. package/extensions/ui-customization/index.ts +0 -4
  61. package/extensions/user-input-fold/index.ts +1 -1
  62. package/extensions/web/index.ts +234 -0
  63. package/extensions/workflows/artifacts.ts +137 -47
  64. package/extensions/workflows/completion-projection.ts +457 -0
  65. package/extensions/workflows/coordinator.ts +8 -10
  66. package/extensions/workflows/dashboard.ts +167 -228
  67. package/extensions/workflows/handoff.ts +70 -16
  68. package/extensions/workflows/index.ts +488 -198
  69. package/extensions/workflows/journal.ts +148 -13
  70. package/extensions/workflows/model.ts +74 -4
  71. package/extensions/workflows/navigation.ts +32 -8
  72. package/extensions/workflows/progress-projection.ts +306 -0
  73. package/extensions/workflows/prompt.ts +66 -6
  74. package/extensions/workflows/replay-safety.ts +42 -21
  75. package/extensions/workflows/result-delivery.ts +128 -64
  76. package/extensions/workflows/retention.ts +593 -0
  77. package/extensions/workflows/runner.ts +388 -279
  78. package/extensions/workflows/sandbox-child.cjs +25 -3
  79. package/extensions/workflows/sandbox.ts +62 -8
  80. package/extensions/workflows/serialization.ts +325 -17
  81. package/extensions/workflows/tool-renderer.ts +22 -0
  82. package/extensions/workflows/transcript.ts +149 -0
  83. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  84. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  85. package/package.json +28 -8
  86. package/skills/subagents/REFERENCE.md +189 -0
  87. package/skills/subagents/SKILL.md +1 -1
  88. package/skills/workflows/REFERENCE.md +4 -2
  89. package/web/adapter/pi-adapter.ts +661 -0
  90. package/web/host/browser-launcher.ts +20 -0
  91. package/web/host/static-assets.ts +4 -0
  92. package/web/host/terminal-status.ts +38 -0
  93. package/web/host/web-host.ts +789 -0
  94. package/web/http-dispatcher.ts +125 -0
  95. package/web/protocol/types.ts +462 -0
  96. package/web/runtime/pi-runtime.ts +991 -0
  97. package/web/runtime/types.ts +71 -0
  98. package/web/runtime/web-host-lease.ts +497 -0
  99. package/web/trace.ts +18 -0
  100. package/web/ui/app.js +1398 -0
  101. package/web/ui/index.html +139 -0
  102. package/web/ui/styles.css +598 -0
  103. package/web/vite.config.mjs +34 -0
  104. package/extensions/execution-convergence/active-evidence.ts +0 -129
  105. package/extensions/execution-convergence/index.ts +0 -442
  106. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  107. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  108. package/extensions/setup/intercom.ts +0 -603
  109. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -27,6 +27,7 @@ import {
27
27
  Stream,
28
28
  } from "effect";
29
29
  import type { SubagentBackend, SubagentSession } from "./backend.ts";
30
+ import type { AgentToolRenderer } from "../../shared/agent-tool-renderer.ts";
30
31
  import { BackendRegistry } from "./backend.ts";
31
32
  import type {
32
33
  BackendName,
@@ -63,7 +64,7 @@ const ENTRY_CLOSE_TIMEOUT_MS = 10_000;
63
64
  * First-response watchdog: a run whose provider accepts the request but
64
65
  * never emits an assistant event is settled as a failure so it cannot
65
66
  * occupy a concurrency slot forever. Matches the workflow runner's
66
- * FIRST_RESPONSE_TIMEOUT_MS (extensions/workflows/runner.ts).
67
+ * MODEL_PROGRESS_TIMEOUT_MS (extensions/workflows/runner.ts).
67
68
  */
68
69
  export const FIRST_RESPONSE_TIMEOUT_MS = 45_000;
69
70
  const ERROR_TEXT_MAX_LENGTH = 4_096;
@@ -92,6 +93,9 @@ function appendTranscript(snapshot: MutableSnapshot, item: TranscriptItem) {
92
93
  snapshot.transcript.length - MAX_TRANSCRIPT_ITEMS,
93
94
  );
94
95
  }
96
+ // Monotonic version so UI projections can detect transcript changes by
97
+ // O(1) comparison instead of scanning the mutable array.
98
+ snapshot.transcriptVersion++;
95
99
  }
96
100
 
97
101
  // --- Internal state -----------------------------------------------------------
@@ -105,12 +109,16 @@ interface MutableSnapshot {
105
109
  prompt: string;
106
110
  cwd: string;
107
111
  status: SubagentStatus;
112
+ outcome?: SubagentSnapshot["outcome"];
113
+ worktreeBranch?: string;
108
114
  createdAt: number;
109
115
  settledAt?: number;
110
116
  errorText?: string;
111
117
  meta: SubagentMeta;
112
118
  usage: { tokens?: number; contextWindow?: number };
113
119
  transcript: TranscriptItem[];
120
+ /** Bumped on every transcript mutation; UI caches key on this. */
121
+ transcriptVersion: number;
114
122
  liveAssistant?: { text: string; thinking: string };
115
123
  liveTools: LiveToolState[];
116
124
  queued: SubagentSnapshot["queued"];
@@ -137,6 +145,8 @@ interface Entry {
137
145
  export interface SubagentReadModel {
138
146
  list(): ReadonlyArray<SubagentSnapshot>;
139
147
  get(id: string): SubagentSnapshot | undefined;
148
+ /** Native tool projection retained by the live child session, when present. */
149
+ getToolRenderer?(id: string): AgentToolRenderer | undefined;
140
150
  size(): number;
141
151
  /** Any-change notification (footer status, dashboard). */
142
152
  subscribe(listener: () => void): () => void;
@@ -299,7 +309,20 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
299
309
  };
300
310
 
301
311
  const closeEntryScope = (entry: Entry) =>
302
- Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
312
+ Scope.close(entry.scope, Exit.void).pipe(
313
+ Effect.tap(() =>
314
+ Effect.sync(() => {
315
+ const receipt = entry.session.cleanupReceipt?.();
316
+ if (!receipt?.uncertain) return;
317
+ const current = entry.snapshot.errorText;
318
+ entry.snapshot.errorText = current
319
+ ? `${current}; ${receipt.message}`
320
+ : receipt.message;
321
+ notify(entry.snapshot.id);
322
+ }),
323
+ ),
324
+ Effect.ignore,
325
+ );
303
326
 
304
327
  const pruneSettled = () => {
305
328
  if (entries.size <= MAX_TRACKED) return;
@@ -337,11 +360,13 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
337
360
  switch (outcome._tag) {
338
361
  case "Completed":
339
362
  s.status = "done";
363
+ s.outcome = "completed";
340
364
  s.errorText = undefined;
341
365
  s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
342
366
  break;
343
367
  case "Failed":
344
368
  s.status = "error";
369
+ s.outcome = "failed";
345
370
  s.errorText = bounded(outcome.errorText);
346
371
  // Never let a failed run report the previous run's successful output.
347
372
  s.finalText = (outcome.partialText ?? "").slice(
@@ -351,6 +376,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
351
376
  break;
352
377
  case "Interrupted":
353
378
  s.status = "error";
379
+ s.outcome = "interrupted";
354
380
  s.errorText = "Run was aborted";
355
381
  s.finalText = (outcome.partialText ?? "").slice(
356
382
  0,
@@ -417,6 +443,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
417
443
  case "RunStarted":
418
444
  entry.restarting = false;
419
445
  s.status = "running";
446
+ s.outcome = undefined;
420
447
  s.settledAt = undefined;
421
448
  s.errorText = undefined;
422
449
  armWatchdog(entry);
@@ -578,10 +605,14 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
578
605
  prompt: task.prompt,
579
606
  cwd: task.cwd,
580
607
  status: "running",
608
+ ...(task.worktree
609
+ ? { worktreeBranch: task.worktree.branch }
610
+ : {}),
581
611
  createdAt: Date.now(),
582
612
  meta,
583
613
  usage: { contextWindow: meta.contextWindow },
584
614
  transcript: [],
615
+ transcriptVersion: 0,
585
616
  liveTools: [],
586
617
  queued: [],
587
618
  finalText: "",
@@ -792,6 +823,7 @@ const makeManager = (config: SubagentManagerConfig = {}) =>
792
823
  const view: SubagentReadModel = {
793
824
  list: () => [...entries.values()].map((entry) => entry.snapshot),
794
825
  get: (id) => entries.get(id)?.snapshot,
826
+ getToolRenderer: (id) => entries.get(id)?.session.toolRenderer,
795
827
  size: () => entries.size,
796
828
  subscribe: (listener) => {
797
829
  listeners.add(listener);
@@ -285,8 +285,11 @@ export const SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS = {
285
285
  export const SUBAGENT_LIST_TOOL_DESCRIPTION =
286
286
  "List all subagents (running and finished) with their status.";
287
287
 
288
- /** Builds the child completion/failure wrapper injected into the parent model's context. */
289
- export function buildSubagentResultMessage(options: {
288
+ const SUBAGENT_RESULT_TRANSPORT_INSTRUCTION =
289
+ "(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)";
290
+
291
+ /** Builds the user-visible child completion/failure projection. */
292
+ export function buildSubagentResultDisplayMessage(options: {
290
293
  id: string;
291
294
  title: string;
292
295
  status: "running" | "done" | "error";
@@ -297,9 +300,34 @@ export function buildSubagentResultMessage(options: {
297
300
  let text = `Subagent ${options.id} "${options.title}" ${verb}.`;
298
301
  if (options.errorText) text += `\nError: ${options.errorText}`;
299
302
  text += `\n\n${options.output}`;
303
+ return text;
304
+ }
305
+
306
+ /** Builds the child completion/failure wrapper injected into the parent model's context. */
307
+ export function buildSubagentResultMessage(options: {
308
+ id: string;
309
+ title: string;
310
+ status: "running" | "done" | "error";
311
+ errorText?: string;
312
+ output: string;
313
+ }) {
314
+ let text = buildSubagentResultDisplayMessage(options);
300
315
  // This message is already displayed to the user, so tell the parent to act on
301
316
  // it rather than reprint it verbatim.
302
- text +=
303
- "\n\n(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)";
317
+ text += `\n\n${SUBAGENT_RESULT_TRANSPORT_INSTRUCTION}`;
304
318
  return text;
305
319
  }
320
+
321
+ /** Remove the transport-only suffix from results persisted before the split. */
322
+ export function stripSubagentResultTransportInstruction(content: string) {
323
+ const separator = `\n\n${SUBAGENT_RESULT_TRANSPORT_INSTRUCTION}`;
324
+ const withoutBatchedSeparators = content.replaceAll(
325
+ `${separator}\n\nSubagent `,
326
+ "\n\nSubagent ",
327
+ );
328
+ return (
329
+ withoutBatchedSeparators.endsWith(separator)
330
+ ? withoutBatchedSeparators.slice(0, -separator.length)
331
+ : withoutBatchedSeparators
332
+ ).trimEnd();
333
+ }
@@ -20,6 +20,7 @@ export interface ResultProjection {
20
20
  readonly text: string;
21
21
  readonly truncated: boolean;
22
22
  readonly artifactPath?: string;
23
+ readonly artifactSaveFailed?: boolean;
23
24
  }
24
25
 
25
26
  function sliceStartToUtf8Bytes(content: string, maxBytes: number) {
@@ -96,11 +97,13 @@ export function projectResult(
96
97
  const tailLines = Math.max(1, options.maxLines - headLines);
97
98
 
98
99
  let artifactPath: string | undefined;
100
+ let artifactSaveFailed = false;
99
101
  try {
100
102
  artifactPath = options.writeArtifact(content);
101
103
  } catch {
102
104
  // Delivery is more important than the optional recovery cache. The footer
103
105
  // below stays explicit so a failed write never advertises a false path.
106
+ artifactSaveFailed = true;
104
107
  }
105
108
 
106
109
  let bodyBudget = options.maxBytes;
@@ -138,5 +141,6 @@ export function projectResult(
138
141
  text,
139
142
  truncated: true,
140
143
  ...(artifactPath ? { artifactPath } : {}),
144
+ ...(artifactSaveFailed ? { artifactSaveFailed: true } : {}),
141
145
  };
142
146
  }
@@ -1,3 +1,5 @@
1
+ import type { ConsumableResultDeliveryQueue } from "../../shared/result-delivery.ts";
2
+
1
3
  export interface SubagentResultDeliveryOptions<T> {
2
4
  /** True only when the parent has no run or queued continuation in flight. */
3
5
  readonly isIdle: () => boolean;
@@ -46,7 +48,7 @@ export function createSubagentResultDelivery<T extends { id: string }>(
46
48
  }
47
49
  };
48
50
 
49
- return {
51
+ const queue = {
50
52
  defer(result: T) {
51
53
  pending.set(result.id, result);
52
54
  if (options.isIdle()) flush();
@@ -61,5 +63,9 @@ export function createSubagentResultDelivery<T extends { id: string }>(
61
63
  clear() {
62
64
  pending.clear();
63
65
  },
66
+ size() {
67
+ return pending.size;
68
+ },
64
69
  };
70
+ return queue satisfies ConsumableResultDeliveryQueue<T>;
65
71
  }
@@ -11,8 +11,22 @@ import { BackendRegistry, type SubagentBackend } from "./backend.ts";
11
11
  import { piBackend } from "./backends/pi.ts";
12
12
  import type { BackendName } from "./domain.ts";
13
13
 
14
+ /**
15
+ * Test-only injection seam for extension-level tests that drive real spawns
16
+ * without a child pi session: production never sets it. The underscore-prefixed
17
+ * setter name makes any accidental production use self-evidently wrong.
18
+ */
19
+ let testBackends: readonly SubagentBackend[] | undefined;
20
+
21
+ /** Test-only: replace the backends the manager can spawn against. */
22
+ export function __setSubagentTestBackends(
23
+ backends: readonly SubagentBackend[] | undefined,
24
+ ) {
25
+ testBackends = backends;
26
+ }
27
+
14
28
  const BackendRegistryLive = Layer.sync(BackendRegistry, () => {
15
- const backends: SubagentBackend[] = [piBackend];
29
+ const backends: readonly SubagentBackend[] = testBackends ?? [piBackend];
16
30
  return new Map<BackendName, SubagentBackend>(
17
31
  backends.map((backend) => [backend.name, backend]),
18
32
  );
@@ -2,8 +2,8 @@
2
2
  * Takeover UI for subagents (ported from v1, rendering from the synchronous
3
3
  * SubagentReadModel instead of live pi sessions):
4
4
  * - SubagentDashboard: compact picker docked above the input, listing all subagents.
5
- * - TakeoverView: full interactive view of one subagent with an input line
6
- * to steer/continue it.
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,22 +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";
16
- import {
17
- hintLine,
18
- panelFrame,
19
- type ScreenHint,
20
- } from "../../../shared/screen-chrome.ts";
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";
21
18
  import { sanitizeTerminalText } from "../../../shared/terminal-text.ts";
22
19
  import { formatElapsed, type SubagentSnapshot } from "../domain.ts";
23
20
  import { formatContextUtilization } from "../../../shared/context-utilization.ts";
21
+ import { SPINNER_INTERVAL_MS, spinnerFrame } from "../../../shared/spinner.ts";
24
22
  import type { SubagentReadModel } from "../manager.ts";
25
- import {
26
- buildTranscriptLines,
27
- SPINNER_INTERVAL_MS,
28
- spinnerFrame,
29
- TranscriptRenderer,
30
- } from "./transcript.ts";
23
+ import { subagentTranscriptDocument } from "./transcript.ts";
31
24
 
32
25
  export function sanitizeSubagentDisplayLine(value: string) {
33
26
  return sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
@@ -43,7 +36,7 @@ function configuredKeys(
43
36
  /**
44
37
  * One spinner definition for the whole subagent UI: the dashboard glyph, the
45
38
  * takeover header, and the transcript's live tools must animate in step, so the
46
- * 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.
47
40
  */
48
41
  function statusGlyph(
49
42
  snap: SubagentSnapshot,
@@ -69,6 +62,8 @@ function statusGlyph(
69
62
 
70
63
  export interface TakeoverOptions {
71
64
  readonly badge?: string;
65
+ /** Captured from the parent UI when this child page opens. */
66
+ readonly toolsExpanded?: boolean;
72
67
  }
73
68
 
74
69
  export async function openSubagentTakeover(
@@ -78,12 +73,24 @@ export async function openSubagentTakeover(
78
73
  options?: TakeoverOptions,
79
74
  ) {
80
75
  if (!view.get(id)) return;
76
+ const takeoverOptions: TakeoverOptions = {
77
+ ...options,
78
+ toolsExpanded: ctx.ui.getToolsExpanded(),
79
+ };
81
80
  await ctx.ui.custom<null>(
82
81
  (tui, theme, keybindings, done) =>
83
- 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
+ ),
84
91
  {
85
92
  overlay: true,
86
- overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
93
+ overlayOptions: { anchor: "top-left", width: "100%", maxHeight: "100%" },
87
94
  },
88
95
  );
89
96
  }
@@ -387,33 +394,18 @@ export class SubagentDashboard implements Component {
387
394
 
388
395
  // --- Takeover view ------------------------------------------------------------
389
396
 
390
- const TRANSCRIPT_SCROLL_STEP = 6;
391
-
392
397
  export class TakeoverView implements Component, Focusable {
393
- private tui: TUI;
394
- private theme: Theme;
395
- private keybindings: KeybindingsManager;
396
- private id: string;
397
- private view: SubagentReadModel;
398
- private done: (value: null) => void;
399
- private options?: TakeoverOptions;
400
-
401
- private input = new Input();
402
- private transcriptRenderer = new TranscriptRenderer();
403
- /** Scroll offset in lines from the bottom of the transcript. 0 = pinned to bottom. */
404
- private scrollOffset = 0;
398
+ private page: AgentSessionPage;
405
399
  private unsubscribe: () => void;
406
400
  private renderTimer?: ReturnType<typeof setTimeout>;
407
401
  private ticker?: ReturnType<typeof setInterval>;
408
402
  private closed = false;
409
403
 
410
- private _focused = false;
411
- get focused(): boolean {
412
- return this._focused;
404
+ get focused() {
405
+ return this.page.focused;
413
406
  }
414
407
  set focused(value: boolean) {
415
- this._focused = value;
416
- this.input.focused = value;
408
+ this.page.focused = value;
417
409
  }
418
410
 
419
411
  constructor(
@@ -425,46 +417,58 @@ export class TakeoverView implements Component, Focusable {
425
417
  done: (value: null) => void,
426
418
  options?: TakeoverOptions,
427
419
  ) {
428
- this.tui = tui;
429
- this.theme = theme;
430
- this.keybindings = keybindings;
431
- this.id = id;
432
- this.view = view;
433
- this.done = done;
434
- 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
+ );
435
450
  this.unsubscribe = view.subscribeTo(id, () => {
436
- this.refreshTicker();
437
- this.scheduleRender();
451
+ this.refreshTicker(view.get(id), tui);
452
+ this.scheduleRender(tui);
438
453
  });
439
- this.refreshTicker();
440
- this.input.onSubmit = (value: string) => {
441
- const text = value.trim();
442
- if (!text) return;
443
- this.input.setValue("");
444
- this.view.requestSend(this.id, text);
445
- this.scrollOffset = 0;
446
- this.tui.requestRender();
447
- };
454
+ this.refreshTicker(view.get(id), tui);
448
455
  }
449
456
 
450
- private snap(): SubagentSnapshot | undefined {
451
- return this.view.get(this.id);
452
- }
453
-
454
- private refreshTicker() {
455
- const interval =
456
- this.snap()?.status === "running" ? SPINNER_INTERVAL_MS : 1000;
457
+ private refreshTicker(snap: SubagentSnapshot | undefined, tui: TUI) {
457
458
  if (this.ticker) clearInterval(this.ticker);
458
- 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
+ }
459
463
  }
460
464
 
461
- private scheduleRender() {
465
+ private scheduleRender(tui: TUI) {
462
466
  if (this.renderTimer) return;
463
467
  // Streaming can emit an event per token. Limit terminal repaints so this
464
468
  // view cannot starve input handling or make the child look frozen.
465
469
  this.renderTimer = setTimeout(() => {
466
470
  this.renderTimer = undefined;
467
- if (!this.closed) this.tui.requestRender();
471
+ if (!this.closed) tui.requestRender();
468
472
  }, 50);
469
473
  }
470
474
 
@@ -478,8 +482,8 @@ export class TakeoverView implements Component, Focusable {
478
482
  return true;
479
483
  }
480
484
 
481
- private close() {
482
- if (this.cleanup()) this.done(null);
485
+ private close(done: (value: null) => void) {
486
+ if (this.cleanup()) done(null);
483
487
  }
484
488
 
485
489
  dispose(): void {
@@ -487,202 +491,14 @@ export class TakeoverView implements Component, Focusable {
487
491
  }
488
492
 
489
493
  handleInput(data: string): void {
490
- if (this.keybindings.matches(data, "app.clear")) {
491
- const snap = this.snap();
492
- if (snap?.status === "running") this.view.requestAbort(this.id);
493
- return;
494
- }
495
- if (
496
- this.keybindings.matches(data, "app.interrupt") ||
497
- this.keybindings.matches(data, "tui.select.cancel")
498
- ) {
499
- this.close();
500
- return;
501
- }
502
- if (this.keybindings.matches(data, "tui.editor.cursorUp")) {
503
- this.scrollOffset += TRANSCRIPT_SCROLL_STEP;
504
- this.tui.requestRender();
505
- return;
506
- }
507
- if (this.keybindings.matches(data, "tui.editor.cursorDown")) {
508
- this.scrollOffset = Math.max(
509
- 0,
510
- this.scrollOffset - TRANSCRIPT_SCROLL_STEP,
511
- );
512
- this.tui.requestRender();
513
- return;
514
- }
515
- if (this.keybindings.matches(data, "tui.editor.pageUp")) {
516
- this.scrollOffset += this.viewportHeight();
517
- this.tui.requestRender();
518
- return;
519
- }
520
- if (this.keybindings.matches(data, "tui.editor.pageDown")) {
521
- this.scrollOffset = Math.max(
522
- 0,
523
- this.scrollOffset - this.viewportHeight(),
524
- );
525
- this.tui.requestRender();
526
- return;
527
- }
528
- this.input.handleInput(data);
529
- this.tui.requestRender();
530
- }
531
-
532
- private viewportHeight(): number {
533
- const rows = this.tui.terminal.rows || 30;
534
- // Top rule, transcript rule, input, key hints, and bottom rule are five
535
- // chrome rows. The overlay leaves Pi's final footer row visible.
536
- return Math.max(1, rows - 6);
537
- }
538
-
539
- private rule(width: number, left = "", right = "") {
540
- const fill = "─";
541
- const available = Math.max(1, width);
542
- const leftWidth = visibleWidth(left);
543
- const rightWidth = visibleWidth(right);
544
- if (!right || leftWidth + rightWidth + 2 > available) {
545
- return truncateToWidth(
546
- left + fill.repeat(Math.max(0, available - leftWidth)),
547
- available,
548
- );
549
- }
550
- return (
551
- left +
552
- fill.repeat(Math.max(1, available - leftWidth - rightWidth)) +
553
- right
554
- );
494
+ this.page.handleInput(data);
555
495
  }
556
496
 
557
497
  render(width: number): string[] {
558
- const theme = this.theme;
559
- const now = Date.now();
560
- const lines: string[] = [];
561
- const snap = this.snap();
562
-
563
- if (!snap) {
564
- const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
565
- lines.push(
566
- border,
567
- theme.fg("dim", `${this.id} is no longer tracked`),
568
- border,
569
- );
570
- return lines;
571
- }
572
-
573
- const title = sanitizeSubagentDisplayLine(snap.title) || snap.id;
574
- const headerLeft =
575
- theme.fg("borderAccent", "─ ") +
576
- statusGlyph(snap, theme, now) +
577
- " " +
578
- theme.fg("accent", theme.bold(title)) +
579
- theme.fg("borderAccent", " ");
580
- const utilization = formatContextUtilization(snap.usage);
581
- const metadata = [
582
- ...(this.options?.badge
583
- ? [theme.fg("muted", sanitizeSubagentDisplayLine(this.options.badge))]
584
- : []),
585
- theme.fg(
586
- "muted",
587
- sanitizeSubagentDisplayLine(snap.meta.modelLabel ?? "?") || "?",
588
- ),
589
- ...(utilization ? [theme.fg("muted", utilization)] : []),
590
- theme.fg("muted", formatElapsed(snap)),
591
- ];
592
- const dot = theme.fg("dim", " · ");
593
- while (
594
- metadata.length > 1 &&
595
- visibleWidth(headerLeft) + visibleWidth(metadata.join(dot)) + 2 > width
596
- ) {
597
- metadata.shift();
598
- }
599
- lines.push(
600
- this.rule(
601
- width,
602
- truncateToWidth(
603
- headerLeft,
604
- Math.max(1, width - visibleWidth(metadata.join(dot)) - 2),
605
- ),
606
- metadata.join(dot),
607
- ),
608
- );
609
-
610
- // Fixed-height transcript viewport. Errors consume a row, but scroll state
611
- // is represented by the following rule so its height never changes.
612
- // `now` is shared with the header glyph so both spinners show one frame.
613
- const transcript = buildTranscriptLines(
614
- snap,
615
- width,
616
- theme,
617
- this.transcriptRenderer,
618
- { now },
619
- );
620
- const viewport = this.viewportHeight();
621
- const errorRows = snap.errorText ? 1 : 0;
622
- const transcriptCapacity = Math.max(1, viewport - errorRows);
623
- const maxOffset = Math.max(0, transcript.length - transcriptCapacity);
624
- if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset;
625
-
626
- const body: string[] = [];
627
- if (snap.errorText) {
628
- body.push(
629
- truncateToWidth(
630
- theme.fg(
631
- "error",
632
- `error: ${sanitizeSubagentDisplayLine(snap.errorText)}`,
633
- ),
634
- width,
635
- ),
636
- );
637
- }
638
- const end = transcript.length - this.scrollOffset;
639
- const visible = transcript.slice(
640
- Math.max(0, end - Math.max(1, viewport - body.length)),
641
- end,
642
- );
643
- if (visible.length === 0) body.push(theme.fg("dim", "waiting for output…"));
644
- else body.push(...visible);
645
- while (body.length < viewport) body.push("");
646
- lines.push(...body.slice(0, viewport));
647
-
648
- lines.push(
649
- this.rule(
650
- width,
651
- theme.fg("borderAccent", "─"),
652
- this.scrollOffset > 0 ? theme.fg("dim", `↓ ${this.scrollOffset}`) : "",
653
- ),
654
- );
655
- lines.push(...this.input.render(width));
656
- const keys = (binding: Parameters<KeybindingsManager["getKeys"]>[0]) =>
657
- configuredKeys(this.keybindings, binding);
658
- const editing: ScreenHint[] = [
659
- [keys("tui.input.submit"), "send"],
660
- [keys("app.interrupt"), "back"],
661
- [keys("app.clear"), "abort run"],
662
- [
663
- `${keys("tui.editor.cursorUp")}/${keys("tui.editor.cursorDown")}`,
664
- "scroll",
665
- ],
666
- ];
667
- // Same drop-the-page-hint fallback as before, measured on the styled line
668
- // so the keys-brighter-than-labels styling cannot change what fits.
669
- const full = hintLine(
670
- theme,
671
- [
672
- ...editing,
673
- [`${keys("tui.editor.pageUp")}/${keys("tui.editor.pageDown")}`, "page"],
674
- ],
675
- width,
676
- );
677
- lines.push(
678
- visibleWidth(full) <= width ? full : hintLine(theme, editing, width),
679
- );
680
- lines.push(this.rule(width, theme.fg("borderAccent", "─")));
681
- return lines;
498
+ return this.page.render(width);
682
499
  }
683
500
 
684
501
  invalidate(): void {
685
- this.input.invalidate();
686
- this.transcriptRenderer.invalidate();
502
+ this.page.invalidate();
687
503
  }
688
504
  }