@oh-my-pi/pi-coding-agent 16.0.9 → 16.0.11

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 (110) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/cli.js +3402 -3443
  3. package/dist/types/advisor/index.d.ts +1 -0
  4. package/dist/types/advisor/transcript-recorder.d.ts +52 -0
  5. package/dist/types/collab/host.d.ts +2 -2
  6. package/dist/types/collab/protocol.d.ts +4 -5
  7. package/dist/types/commit/agentic/agent.d.ts +1 -1
  8. package/dist/types/config/model-resolver.d.ts +11 -2
  9. package/dist/types/config/settings-schema.d.ts +12 -6
  10. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  12. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  13. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  16. package/dist/types/modes/interactive-mode.d.ts +2 -1
  17. package/dist/types/modes/types.d.ts +2 -1
  18. package/dist/types/registry/agent-registry.d.ts +10 -3
  19. package/dist/types/session/agent-session.d.ts +13 -0
  20. package/dist/types/session/compact-modes.d.ts +60 -0
  21. package/dist/types/session/streaming-output.d.ts +0 -2
  22. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  23. package/dist/types/slash-commands/helpers/collab-qrcode.d.ts +13 -0
  24. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  25. package/dist/types/tools/index.d.ts +9 -1
  26. package/dist/types/utils/image-loading.d.ts +12 -0
  27. package/dist/types/utils/qrcode.d.ts +48 -0
  28. package/package.json +12 -12
  29. package/src/advisor/index.ts +1 -0
  30. package/src/advisor/transcript-recorder.ts +136 -0
  31. package/src/cli/args.ts +7 -1
  32. package/src/cli/stats-cli.ts +2 -11
  33. package/src/collab/host.ts +29 -17
  34. package/src/collab/protocol.ts +48 -15
  35. package/src/commit/agentic/agent.ts +2 -1
  36. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  37. package/src/commit/changelog/index.ts +1 -1
  38. package/src/commit/map-reduce/map-phase.ts +1 -1
  39. package/src/commit/map-reduce/utils.ts +1 -1
  40. package/src/config/config-file.ts +1 -1
  41. package/src/config/keybindings.ts +2 -2
  42. package/src/config/model-registry.ts +16 -4
  43. package/src/config/model-resolver.ts +193 -35
  44. package/src/config/settings-schema.ts +14 -7
  45. package/src/config/settings.ts +3 -9
  46. package/src/edit/file-snapshot-store.ts +1 -1
  47. package/src/edit/renderer.ts +7 -7
  48. package/src/eval/js/tool-bridge.ts +3 -2
  49. package/src/eval/py/prelude.py +3 -2
  50. package/src/export/html/tool-views.generated.js +28 -28
  51. package/src/extensibility/extensions/types.ts +7 -0
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/docs-index.generated.txt +1 -1
  54. package/src/internal-urls/history-protocol.ts +8 -3
  55. package/src/irc/bus.ts +8 -0
  56. package/src/lsp/index.ts +2 -2
  57. package/src/main.ts +6 -3
  58. package/src/modes/acp/acp-agent.ts +63 -0
  59. package/src/modes/components/agent-hub.ts +97 -920
  60. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  61. package/src/modes/components/chat-transcript-builder.ts +462 -0
  62. package/src/modes/components/diff.ts +12 -35
  63. package/src/modes/components/oauth-selector.ts +31 -2
  64. package/src/modes/controllers/command-controller.ts +12 -2
  65. package/src/modes/controllers/event-controller.ts +1 -1
  66. package/src/modes/controllers/input-controller.ts +8 -1
  67. package/src/modes/controllers/selector-controller.ts +4 -1
  68. package/src/modes/interactive-mode.ts +4 -2
  69. package/src/modes/types.ts +2 -1
  70. package/src/prompts/tools/inspect-image.md +1 -1
  71. package/src/prompts/tools/read.md +1 -1
  72. package/src/registry/agent-registry.ts +13 -4
  73. package/src/sdk.ts +27 -8
  74. package/src/session/agent-session.ts +185 -17
  75. package/src/session/compact-modes.ts +105 -0
  76. package/src/session/session-dump-format.ts +1 -1
  77. package/src/session/session-history-format.ts +1 -1
  78. package/src/session/streaming-output.ts +5 -5
  79. package/src/slash-commands/builtin-registry.ts +45 -15
  80. package/src/slash-commands/helpers/collab-qrcode.ts +28 -0
  81. package/src/task/executor.ts +1 -1
  82. package/src/task/output-manager.ts +5 -0
  83. package/src/thinking.ts +25 -5
  84. package/src/tools/__tests__/json-tree.test.ts +35 -0
  85. package/src/tools/approval.ts +1 -1
  86. package/src/tools/bash.ts +0 -1
  87. package/src/tools/browser.ts +0 -1
  88. package/src/tools/eval.ts +1 -1
  89. package/src/tools/gh.ts +1 -1
  90. package/src/tools/index.ts +10 -1
  91. package/src/tools/inspect-image.ts +72 -9
  92. package/src/tools/irc.ts +1 -1
  93. package/src/tools/json-tree.ts +22 -5
  94. package/src/tools/read.ts +5 -6
  95. package/src/utils/file-mentions.ts +5 -2
  96. package/src/utils/image-loading.ts +58 -0
  97. package/src/utils/qrcode.ts +535 -0
  98. package/src/web/scrapers/firefox-addons.ts +1 -1
  99. package/src/web/scrapers/github.ts +1 -1
  100. package/src/web/scrapers/go-pkg.ts +2 -2
  101. package/src/web/scrapers/metacpan.ts +2 -2
  102. package/src/web/scrapers/nvd.ts +2 -2
  103. package/src/web/scrapers/ollama.ts +1 -1
  104. package/src/web/scrapers/opencorporates.ts +1 -1
  105. package/src/web/scrapers/pub-dev.ts +1 -1
  106. package/src/web/scrapers/repology.ts +1 -1
  107. package/src/web/scrapers/sourcegraph.ts +1 -1
  108. package/src/web/scrapers/terraform.ts +6 -6
  109. package/src/web/scrapers/wikidata.ts +2 -2
  110. package/src/workspace-tree.ts +1 -1
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Fullscreen transcript viewer.
3
+ *
4
+ * `AgentHubOverlayComponent.openChat` mounts this as a `fullscreen` overlay
5
+ * (`ui.showOverlay(..., { fullscreen: true })`), so it borrows the terminal's
6
+ * alternate screen buffer (the vim/less idiom) and paints the whole screen — no
7
+ * compositing into the live transcript's scrollback. It renders a parked
8
+ * subagent / advisor / collab-guest transcript that has no live in-view session.
9
+ *
10
+ * The transcript is rebuilt from scratch on every refresh ({@link ChatTranscriptBuilder.rebuild})
11
+ * rather than synced incrementally, so a growing file-backed transcript (the
12
+ * advisor appends while you watch) can never duplicate or misorder rows. Scroll
13
+ * is owned end-to-end by a single {@link ScrollView}; the viewer follows the tail
14
+ * until the reader scrolls up.
15
+ *
16
+ * Local agents re-read the whole session file whenever its size or mtime changes
17
+ * (covering SessionManager's in-place rewrites, not just appends). Collab guests
18
+ * keep the incremental byte cursor the host's capped `readTranscript` requires
19
+ * and rebuild components from the accumulated entries.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
23
+ import { type Component, Editor, matchesKey, parseSgrMouse, ScrollView, type TUI } from "@oh-my-pi/pi-tui";
24
+ import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
25
+ import type { KeyId } from "../../config/keybindings";
26
+ import type { MessageRenderer } from "../../extensibility/extensions/types";
27
+ import type { AgentLifecycleManager } from "../../registry/agent-lifecycle";
28
+ import type { AgentRegistry, AgentStatus } from "../../registry/agent-registry";
29
+ import type { FileEntry, SessionMessageEntry } from "../../session/session-entries";
30
+ import { parseSessionEntries } from "../../session/session-loader";
31
+ import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
32
+ import { getEditorTheme, theme } from "../theme/theme";
33
+ import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
34
+ import type { AgentHubRemote } from "./agent-hub";
35
+ import { ChatTranscriptBuilder } from "./chat-transcript-builder";
36
+ import { DynamicBorder } from "./dynamic-border";
37
+ import { formatContextUsage } from "./status-line/context-thresholds";
38
+
39
+ export interface AgentTranscriptViewerDeps {
40
+ agentId: string;
41
+ registry: AgentRegistry;
42
+ /** Collab guest: read transcript from the host instead of a local file. */
43
+ remote?: AgentHubRemote;
44
+ /** Progress/cost snapshot source for the stats line. */
45
+ observers?: SessionObserverRegistry;
46
+ /** Revive+prompt path for messageable local agents. Lazy to avoid touching the global. */
47
+ lifecycle?: () => AgentLifecycleManager;
48
+ ui: TUI;
49
+ getTool?: (name: string) => AgentTool | undefined;
50
+ getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
51
+ cwd: string;
52
+ hideThinkingBlock?: () => boolean;
53
+ expandKeys: KeyId[];
54
+ /** Keys that toggle the whole hub closed (app.agents.hub + app.session.observe). */
55
+ hubKeys: KeyId[];
56
+ requestRender: () => void;
57
+ /** Close just this viewer (Esc), returning to the hub table. */
58
+ onClose: () => void;
59
+ /** Close this viewer AND the hub (hub-toggle keys). */
60
+ onHubClose: () => void;
61
+ }
62
+
63
+ /** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
64
+ const POLL_MS = 250;
65
+
66
+ function statusBadge(status: AgentStatus): string {
67
+ switch (status) {
68
+ case "running":
69
+ return theme.fg("success", "running");
70
+ case "idle":
71
+ return theme.fg("accent", "idle");
72
+ case "parked":
73
+ return theme.fg("muted", "parked");
74
+ case "aborted":
75
+ return theme.fg("error", "aborted");
76
+ }
77
+ }
78
+
79
+ export class AgentTranscriptViewer implements Component {
80
+ #builder: ChatTranscriptBuilder;
81
+ #scrollView: ScrollView;
82
+ #followBottom = true;
83
+ #editor: Editor | undefined;
84
+ #notice: string | undefined;
85
+ #expanded = false;
86
+
87
+ // Local file transcript state: re-read when the file size or mtime changes.
88
+ #lastSignature = "";
89
+ // Remote transcript state (incremental; the host caps each read).
90
+ #remoteEntries: SessionMessageEntry[] = [];
91
+ #remoteBytes = 0;
92
+ #remoteFetchInFlight = false;
93
+ #remoteToken = 0;
94
+ #remoteUnavailable = false;
95
+ #hasRemoteData = false;
96
+
97
+ #model: string | undefined;
98
+ #pollTimer: NodeJS.Timeout | undefined;
99
+ #disposed = false;
100
+
101
+ constructor(private readonly deps: AgentTranscriptViewerDeps) {
102
+ this.#builder = new ChatTranscriptBuilder({
103
+ ui: deps.ui,
104
+ getTool: deps.getTool,
105
+ getMessageRenderer: deps.getMessageRenderer,
106
+ cwd: deps.cwd,
107
+ hideThinkingBlock: deps.hideThinkingBlock,
108
+ requestRender: deps.requestRender,
109
+ });
110
+ this.#scrollView = new ScrollView([], {
111
+ height: 10,
112
+ scrollbar: "auto",
113
+ theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
114
+ });
115
+ if (this.#sendable) {
116
+ this.#editor = new Editor(getEditorTheme());
117
+ this.#editor.setMaxHeight(4);
118
+ this.#editor.onSubmit = text => this.#submit(text);
119
+ }
120
+ this.#refresh();
121
+ this.#pollTimer = setInterval(() => this.#refresh(), POLL_MS);
122
+ this.#pollTimer.unref?.();
123
+ }
124
+
125
+ /** Advisor transcripts are read-only; everything else may be messaged. */
126
+ get #sendable(): boolean {
127
+ const ref = this.deps.registry.get(this.deps.agentId);
128
+ if (!ref || ref.kind === "advisor") return false;
129
+ return Boolean(this.deps.remote || this.deps.lifecycle);
130
+ }
131
+
132
+ dispose(): void {
133
+ this.#disposed = true;
134
+ if (this.#pollTimer) {
135
+ clearInterval(this.#pollTimer);
136
+ this.#pollTimer = undefined;
137
+ }
138
+ this.#remoteToken++;
139
+ this.#builder.dispose();
140
+ }
141
+
142
+ // ========================================================================
143
+ // Transcript loading
144
+ // ========================================================================
145
+
146
+ /** Re-read the transcript and rebuild components when it changed. */
147
+ #refresh(): void {
148
+ if (this.#disposed) return;
149
+ if (this.deps.remote) {
150
+ this.#fetchRemote();
151
+ return;
152
+ }
153
+ const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile;
154
+ if (!sessionFile) {
155
+ if (this.#lastSignature !== "none") {
156
+ this.#lastSignature = "none";
157
+ this.#rebuild([]);
158
+ }
159
+ return;
160
+ }
161
+ let signature: string;
162
+ try {
163
+ const stat = fs.statSync(sessionFile);
164
+ // Include the path: a different file with the same size/mtime must not alias.
165
+ signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`;
166
+ } catch {
167
+ // File deleted/rotated while open (e.g. the owning session was dropped):
168
+ // clear stale content once instead of freezing on it forever.
169
+ if (this.#lastSignature !== "missing") {
170
+ this.#lastSignature = "missing";
171
+ this.#model = undefined;
172
+ this.#rebuild([]);
173
+ }
174
+ return;
175
+ }
176
+ if (signature === this.#lastSignature) return;
177
+ let text: string;
178
+ try {
179
+ text = fs.readFileSync(sessionFile, "utf-8");
180
+ } catch (err) {
181
+ // Leave #lastSignature unchanged so a transient read error retries next poll.
182
+ logger.debug("transcript viewer: read failed", { err: String(err) });
183
+ return;
184
+ }
185
+ this.#lastSignature = signature;
186
+ this.#model = undefined;
187
+ this.#rebuild(this.#extractMessages(parseSessionEntries(text)));
188
+ }
189
+
190
+ #fetchRemote(): void {
191
+ const remote = this.deps.remote;
192
+ if (!remote || this.#remoteFetchInFlight) return;
193
+ const id = this.deps.agentId;
194
+ const fromByte = this.#remoteBytes;
195
+ this.#remoteFetchInFlight = true;
196
+ const token = ++this.#remoteToken;
197
+ void remote
198
+ .readTranscript(id, fromByte)
199
+ .then(result => {
200
+ if (token !== this.#remoteToken || this.#disposed) return;
201
+ this.#remoteFetchInFlight = false;
202
+ if (!result) {
203
+ if (!this.#hasRemoteData && !this.#remoteUnavailable) {
204
+ this.#remoteUnavailable = true;
205
+ this.deps.requestRender();
206
+ }
207
+ return;
208
+ }
209
+ if (result.newSize < fromByte) {
210
+ // Host transcript rotated/truncated — restart from 0.
211
+ this.#remoteBytes = 0;
212
+ this.#remoteEntries = [];
213
+ this.#fetchRemote();
214
+ return;
215
+ }
216
+ this.#remoteUnavailable = false;
217
+ const firstData = !this.#hasRemoteData;
218
+ this.#hasRemoteData = true;
219
+ const lastNewline = result.text.lastIndexOf("\n");
220
+ if (lastNewline >= 0) {
221
+ const completeChunk = result.text.slice(0, lastNewline + 1);
222
+ this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8");
223
+ const parsed = this.#extractMessages(parseSessionEntries(completeChunk));
224
+ if (parsed.length > 0) {
225
+ this.#remoteEntries.push(...parsed);
226
+ this.#rebuild(this.#remoteEntries);
227
+ return;
228
+ }
229
+ }
230
+ // First completed fetch (even empty) clears the "Loading…" placeholder.
231
+ if (firstData) this.deps.requestRender();
232
+ })
233
+ .catch((error: unknown) => {
234
+ if (token === this.#remoteToken) this.#remoteFetchInFlight = false;
235
+ logger.warn("transcript viewer: remote fetch failed", { id, error: String(error) });
236
+ });
237
+ }
238
+
239
+ /** Filter to message entries, tracking the model from the first assistant / a model_change. */
240
+ #extractMessages(entries: FileEntry[]): SessionMessageEntry[] {
241
+ const messages: SessionMessageEntry[] = [];
242
+ for (const entry of entries) {
243
+ if (entry.type === "message") {
244
+ messages.push(entry);
245
+ if (!this.#model && entry.message.role === "assistant") this.#model = entry.message.model;
246
+ } else if (entry.type === "model_change") {
247
+ this.#model = entry.model;
248
+ }
249
+ }
250
+ return messages;
251
+ }
252
+
253
+ #rebuild(entries: SessionMessageEntry[]): void {
254
+ this.#builder.rebuild(entries);
255
+ this.deps.requestRender();
256
+ }
257
+
258
+ // ========================================================================
259
+ // Input
260
+ // ========================================================================
261
+
262
+ handleInput(data: string): void {
263
+ if (data.startsWith("\x1b[<")) {
264
+ const event = parseSgrMouse(data);
265
+ if (event?.wheel != null) {
266
+ this.#scrollView.scroll(event.wheel * 3);
267
+ this.#syncFollow();
268
+ this.deps.requestRender();
269
+ }
270
+ return;
271
+ }
272
+
273
+ // The hub/observe toggle keys close the whole hub (matches the table view's
274
+ // toggle semantics), not just this viewer.
275
+ for (const key of this.deps.hubKeys) {
276
+ if (matchesKey(data, key)) {
277
+ this.deps.onHubClose();
278
+ return;
279
+ }
280
+ }
281
+
282
+ if (matchesKey(data, "escape")) {
283
+ if (this.#editor && this.#editor.getText().trim() !== "") {
284
+ this.#editor.setText("");
285
+ this.deps.requestRender();
286
+ return;
287
+ }
288
+ this.deps.onClose();
289
+ return;
290
+ }
291
+
292
+ for (const key of this.deps.expandKeys) {
293
+ if (matchesKey(data, key)) {
294
+ this.#expanded = !this.#expanded;
295
+ this.#builder.setExpanded(this.#expanded);
296
+ this.deps.requestRender();
297
+ return;
298
+ }
299
+ }
300
+
301
+ // Once the reader starts typing a message, the editor owns every key.
302
+ const editorEmpty = !this.#editor || this.#editor.getText().trim() === "";
303
+ if (editorEmpty && this.#handleScroll(data)) return;
304
+
305
+ if (this.#editor) {
306
+ this.#editor.handleInput(data);
307
+ this.deps.requestRender();
308
+ }
309
+ }
310
+
311
+ /** Returns true when the key was a scroll command. ScrollView owns the offset. */
312
+ #handleScroll(data: string): boolean {
313
+ if (this.#scrollView.handleScrollKey(data)) {
314
+ this.#syncFollow();
315
+ this.deps.requestRender();
316
+ return true;
317
+ }
318
+ if (data === "j" || matchesSelectDown(data)) {
319
+ this.#scrollView.scroll(1);
320
+ } else if (data === "k" || matchesSelectUp(data)) {
321
+ this.#scrollView.scroll(-1);
322
+ } else if (data === "g") {
323
+ this.#scrollView.scrollToTop();
324
+ } else if (data === "G") {
325
+ this.#scrollView.scrollToBottom();
326
+ } else {
327
+ return false;
328
+ }
329
+ this.#syncFollow();
330
+ this.deps.requestRender();
331
+ return true;
332
+ }
333
+
334
+ #syncFollow(): void {
335
+ this.#followBottom = this.#scrollView.getScrollOffset() >= this.#scrollView.getMaxScrollOffset();
336
+ }
337
+
338
+ #submit(text: string): void {
339
+ const trimmed = text.trim();
340
+ this.#editor?.setText("");
341
+ if (!trimmed) return;
342
+ this.#notice = undefined;
343
+ const id = this.deps.agentId;
344
+ if (this.deps.remote) {
345
+ this.deps.remote.chat(id, trimmed);
346
+ this.deps.requestRender();
347
+ return;
348
+ }
349
+ const lifecycle = this.deps.lifecycle;
350
+ if (!lifecycle) return;
351
+ void (async () => {
352
+ try {
353
+ // Revives a parked agent; returns the live session for running/idle.
354
+ const session = await lifecycle().ensureLive(id);
355
+ // Steers a mid-turn agent; sends a normal prompt to an idle one.
356
+ await session.prompt(trimmed, { streamingBehavior: "steer" });
357
+ } catch (error) {
358
+ this.#notice = error instanceof Error ? error.message : String(error);
359
+ }
360
+ this.deps.requestRender();
361
+ })();
362
+ this.deps.requestRender();
363
+ }
364
+
365
+ // ========================================================================
366
+ // Render
367
+ // ========================================================================
368
+
369
+ render(width: number): readonly string[] {
370
+ const termHeight = process.stdout.rows || 40;
371
+ // `innerWidth` widths the editor/notice chrome (gutter-prefixed below).
372
+ // `contentWidth` widths the transcript: ScrollView reserves the last column
373
+ // for the scrollbar, and the transcript components carry their own 1-col left
374
+ // gutter — so body rows are emitted WITHOUT an extra outer space, sharing that
375
+ // gutter with the header/footer (which add one). Stacking both shifted the body
376
+ // one column right of the title.
377
+ const innerWidth = Math.max(20, width - 2);
378
+ const contentWidth = Math.max(1, width - 1);
379
+ const ref = this.deps.registry.get(this.deps.agentId);
380
+
381
+ const headerLines = this.#headerLines(ref?.status, ref?.kind, ref?.parentId);
382
+ const footerLines = this.#footerLines();
383
+ const noticeLine = this.#notice ? ` ${theme.fg("error", this.#notice)}` : undefined;
384
+ const editorLines = this.#editor ? this.#editor.render(innerWidth) : [];
385
+
386
+ // Chrome: top border + header rows + divider border + (notice) + editor + footer + bottom border.
387
+ const chrome = headerLines.length + 2 + editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1;
388
+ const viewportHeight = Math.max(3, termHeight - chrome);
389
+
390
+ const contentLines = this.#builder.isEmpty
391
+ ? [` ${theme.fg("dim", this.#placeholder())}`]
392
+ : this.#builder.container.render(contentWidth);
393
+ this.#scrollView.setLines(contentLines);
394
+ this.#scrollView.setHeight(viewportHeight);
395
+ if (this.#followBottom) this.#scrollView.scrollToBottom();
396
+
397
+ const lines: string[] = [];
398
+ lines.push(...new DynamicBorder().render(width));
399
+ for (const headerLine of headerLines) lines.push(` ${headerLine}`);
400
+ lines.push(...new DynamicBorder().render(width));
401
+ for (const row of this.#scrollView.render(width)) lines.push(row);
402
+ if (noticeLine) lines.push(noticeLine);
403
+ for (const editorLine of editorLines) lines.push(` ${editorLine}`);
404
+ lines.push(...footerLines);
405
+ lines.push(...new DynamicBorder().render(width));
406
+ return lines;
407
+ }
408
+
409
+ #headerLines(status: AgentStatus | undefined, kind: string | undefined, parentId: string | undefined): string[] {
410
+ const lines = [theme.fg("accent", `Agent Hub ${theme.sep.dot} ${this.deps.agentId}`)];
411
+ if (status && kind) {
412
+ const kindTag = theme.fg("dim", ` ${parentId ? `${kind} ${theme.sep.dot} of ${parentId}` : kind}`);
413
+ const modelLabel = this.#model ? theme.fg("muted", `${theme.sep.dot}${this.#model}`) : "";
414
+ lines.push(`${theme.bold(this.deps.agentId)} ${statusBadge(status)}${kindTag}${modelLabel}`);
415
+ }
416
+ return lines;
417
+ }
418
+
419
+ #footerLines(): string[] {
420
+ const lines: string[] = [];
421
+ const statsLine = this.#statsLine();
422
+ if (statsLine) lines.push(` ${statsLine}`);
423
+ const hint = this.#editor
424
+ ? `Enter:send Esc:close ${this.deps.expandKeys[0] ?? "ctrl+o"}:expand empty input → j/k:scroll g/G:top/bottom`
425
+ : `Esc:close ${this.deps.expandKeys[0] ?? "ctrl+o"}:expand j/k:scroll g/G:top/bottom`;
426
+ lines.push(` ${theme.fg("dim", hint)}`);
427
+ return lines;
428
+ }
429
+
430
+ #statsLine(): string {
431
+ const observed: ObservableSession | undefined = this.deps.observers
432
+ ?.getSessions()
433
+ .find(s => s.id === this.deps.agentId);
434
+ const progress = observed?.progress;
435
+ if (!progress) return "";
436
+ const stats: string[] = [];
437
+ if (progress.contextTokens && progress.contextTokens > 0) {
438
+ stats.push(
439
+ progress.contextWindow && progress.contextWindow > 0
440
+ ? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow)
441
+ : formatNumber(progress.contextTokens),
442
+ );
443
+ }
444
+ if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
445
+ const parts: string[] = [];
446
+ if (stats.length > 0 || progress.toolCount > 0) {
447
+ const toolStat =
448
+ progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : "";
449
+ parts.push(theme.fg("dim", [toolStat, ...stats].filter(Boolean).join(theme.sep.dot)));
450
+ }
451
+ if (progress.cost > 0) parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`));
452
+ return parts.join(theme.sep.dot);
453
+ }
454
+
455
+ #placeholder(): string {
456
+ if (this.deps.remote && this.#remoteUnavailable) return "Transcript lives on the host — not available.";
457
+ if (this.deps.remote && !this.#hasRemoteData) return "Loading transcript from host…";
458
+ if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet.";
459
+ return "No messages yet.";
460
+ }
461
+ }