@oh-my-pi/pi-coding-agent 15.11.8 → 15.12.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 (80) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/dist/cli.js +8083 -7692
  3. package/dist/types/collab/crypto.d.ts +1 -6
  4. package/dist/types/collab/guest.d.ts +2 -0
  5. package/dist/types/collab/host.d.ts +16 -0
  6. package/dist/types/collab/protocol.d.ts +14 -1
  7. package/dist/types/config/settings-schema.d.ts +40 -5
  8. package/dist/types/export/custom-share.d.ts +1 -2
  9. package/dist/types/export/html/index.d.ts +39 -1
  10. package/dist/types/export/share.d.ts +43 -0
  11. package/dist/types/main.d.ts +2 -0
  12. package/dist/types/modes/components/agent-hub.d.ts +19 -1
  13. package/dist/types/modes/components/status-line/component.d.ts +6 -1
  14. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  15. package/dist/types/modes/controllers/event-controller.d.ts +7 -0
  16. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  17. package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
  18. package/dist/types/modes/interactive-mode.d.ts +9 -0
  19. package/dist/types/modes/session-observer-registry.d.ts +7 -0
  20. package/dist/types/modes/theme/theme.d.ts +2 -1
  21. package/dist/types/modes/types.d.ts +12 -0
  22. package/dist/types/session/agent-session.d.ts +2 -0
  23. package/dist/types/session/codex-auto-reset.d.ts +8 -4
  24. package/dist/types/task/executor.d.ts +7 -0
  25. package/dist/types/task/types.d.ts +9 -0
  26. package/package.json +13 -14
  27. package/scripts/build-binary.ts +4 -0
  28. package/scripts/bundle-dist.ts +4 -0
  29. package/scripts/generate-share-viewer.ts +34 -0
  30. package/src/collab/crypto.ts +10 -4
  31. package/src/collab/guest.ts +31 -2
  32. package/src/collab/host.ts +73 -11
  33. package/src/collab/protocol.ts +48 -7
  34. package/src/commands/join.ts +1 -1
  35. package/src/config/settings-schema.ts +40 -4
  36. package/src/config/settings.ts +12 -0
  37. package/src/export/custom-share.ts +1 -1
  38. package/src/export/html/index.ts +122 -17
  39. package/src/export/html/share-loader.js +102 -0
  40. package/src/export/html/template.css +745 -459
  41. package/src/export/html/template.html +6 -3
  42. package/src/export/html/template.js +240 -915
  43. package/src/export/html/tool-views.generated.js +38 -0
  44. package/src/export/share.ts +268 -0
  45. package/src/internal-urls/docs-index.generated.ts +73 -73
  46. package/src/main.ts +22 -9
  47. package/src/modes/components/agent-hub.ts +541 -410
  48. package/src/modes/components/status-line/component.ts +38 -5
  49. package/src/modes/components/status-line/segments.ts +5 -1
  50. package/src/modes/components/status-line/types.ts +2 -0
  51. package/src/modes/components/tips.txt +3 -1
  52. package/src/modes/controllers/command-controller.ts +55 -96
  53. package/src/modes/controllers/event-controller.ts +45 -16
  54. package/src/modes/controllers/input-controller.ts +104 -4
  55. package/src/modes/controllers/selector-controller.ts +11 -15
  56. package/src/modes/controllers/session-focus-controller.ts +112 -0
  57. package/src/modes/interactive-mode.ts +44 -2
  58. package/src/modes/session-observer-registry.ts +11 -0
  59. package/src/modes/theme/theme.ts +6 -0
  60. package/src/modes/types.ts +12 -0
  61. package/src/modes/utils/ui-helpers.ts +16 -13
  62. package/src/prompts/tools/job.md +1 -1
  63. package/src/session/agent-session.ts +65 -7
  64. package/src/session/codex-auto-reset.ts +23 -11
  65. package/src/slash-commands/builtin-registry.ts +62 -35
  66. package/src/task/executor.ts +14 -0
  67. package/src/task/index.ts +5 -1
  68. package/src/task/render.ts +76 -5
  69. package/src/task/types.ts +9 -0
  70. package/src/tiny/worker.ts +17 -95
  71. package/src/tools/job.ts +6 -9
  72. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  73. package/dist/types/export/html/template.generated.d.ts +0 -1
  74. package/dist/types/export/html/template.macro.d.ts +0 -5
  75. package/dist/types/tiny/compiled-runtime.d.ts +0 -35
  76. package/scripts/generate-template.ts +0 -33
  77. package/src/bun-imports.d.ts +0 -28
  78. package/src/export/html/template.generated.ts +0 -2
  79. package/src/export/html/template.macro.ts +0 -25
  80. package/src/tiny/compiled-runtime.ts +0 -179
@@ -14,43 +14,62 @@
14
14
  * Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
15
15
  */
16
16
  import * as fs from "node:fs";
17
- import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
18
- import { Container, Editor, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
19
- import { formatAge, formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
17
+ import * as path from "node:path";
18
+ import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
19
+ import { Container, Editor, matchesKey, ScrollView, Text, type TUI } from "@oh-my-pi/pi-tui";
20
+ import { formatAge, formatBytes, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils";
21
+ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
20
22
  import type { KeyId } from "../../config/keybindings";
23
+ import { settings } from "../../config/settings";
24
+ import type { MessageRenderer } from "../../extensibility/extensions/types";
21
25
  import { IrcBus } from "../../irc/bus";
22
26
  import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
23
27
  import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
24
28
  import type { AgentSession } from "../../session/agent-session";
25
- import { isSilentAbort, USER_INTERRUPT_LABEL } from "../../session/messages";
29
+ import {
30
+ type CustomMessage,
31
+ isSilentAbort,
32
+ LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
33
+ resolveAbortLabel,
34
+ SKILL_PROMPT_MESSAGE_TYPE,
35
+ type SkillPromptDetails,
36
+ USER_INTERRUPT_LABEL,
37
+ } from "../../session/messages";
26
38
  import type { SessionMessageEntry } from "../../session/session-manager";
27
39
  import { parseSessionEntries } from "../../session/session-manager";
28
- import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
29
- import { toPathList } from "../../tools/search";
40
+ import { createIrcMessageCard } from "../../tools/irc";
41
+ import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
30
42
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
31
- import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme";
43
+ import { getEditorTheme, theme } from "../theme/theme";
32
44
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
45
+ import { AssistantMessageComponent } from "./assistant-message";
46
+ import { BashExecutionComponent } from "./bash-execution";
47
+ import { BranchSummaryMessageComponent } from "./branch-summary-message";
48
+ import { CollabPromptMessageComponent } from "./collab-prompt-message";
49
+ import { CompactionSummaryMessageComponent } from "./compaction-summary-message";
50
+ import { CustomMessageComponent } from "./custom-message";
33
51
  import { DynamicBorder } from "./dynamic-border";
52
+ import { EvalExecutionComponent } from "./eval-execution";
53
+ import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
54
+ import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group";
55
+ import { SkillMessageComponent } from "./skill-message";
34
56
  import { formatContextUsage } from "./status-line/context-thresholds";
57
+ import { ToolExecutionComponent } from "./tool-execution";
58
+ import { TranscriptBlock, TranscriptContainer } from "./transcript-container";
59
+ import { UserMessageComponent } from "./user-message";
35
60
 
36
- /** Max thinking characters in collapsed state */
37
- const MAX_THINKING_CHARS_COLLAPSED = 200;
38
- /** Max thinking characters in expanded state */
39
- const MAX_THINKING_CHARS_EXPANDED = 4000;
40
- /** Max tool args characters to display */
41
- const MAX_TOOL_ARGS_CHARS = 500;
42
61
  /** Lines per page for PageUp/PageDown */
43
62
  const PAGE_SIZE = 15;
44
- /** Left indent for content under entry headers */
45
- const INDENT = " ";
46
63
  /** Refresh cadence for the relative-time column */
47
64
  const AGE_TICK_MS = 5_000;
48
65
  /** Debounce for live-session transcript refreshes */
49
66
  const CHAT_REFRESH_DEBOUNCE_MS = 80;
67
+ /** Double-tap window for the left-left "go to parent" gesture (matches the editor's). */
68
+ const LEFT_TAP_WINDOW_MS = 500;
50
69
 
51
- /** Compute the max content width for the current terminal, accounting for indent and chrome. */
52
- function contentWidth(indent = INDENT): number {
53
- return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - indent.length - 2);
70
+ /** Compute the max content width for the current terminal, accounting for chrome. */
71
+ function contentWidth(): number {
72
+ return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6);
54
73
  }
55
74
 
56
75
  /** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
@@ -58,13 +77,6 @@ function sanitizeLine(text: string, maxWidth?: number): string {
58
77
  return truncateToWidth(replaceTabs(text), maxWidth ?? contentWidth());
59
78
  }
60
79
 
61
- /** Represents a rendered entry in the viewer for selection/expand tracking */
62
- interface ViewerEntry {
63
- lineStart: number;
64
- lineCount: number;
65
- kind: "thinking" | "text" | "toolCall" | "user";
66
- }
67
-
68
80
  const STATUS_ORDER: Record<AgentStatus, number> = { running: 0, idle: 1, parked: 2, aborted: 3 };
69
81
 
70
82
  /** Glyph + status word, colored per theme status conventions. */
@@ -81,6 +93,38 @@ function statusBadge(status: AgentStatus): string {
81
93
  }
82
94
  }
83
95
 
96
+ function registerPersistedSubagents(registry: AgentRegistry, sessionFile: string | null | undefined): void {
97
+ if (!sessionFile?.endsWith(".jsonl")) return;
98
+ const root = sessionFile.slice(0, -6);
99
+ registerPersistedSubagentsFromDir(registry, root, undefined);
100
+ }
101
+
102
+ function registerPersistedSubagentsFromDir(registry: AgentRegistry, dir: string, parentId: string | undefined): void {
103
+ let entries: fs.Dirent[];
104
+ try {
105
+ entries = fs.readdirSync(dir, { withFileTypes: true });
106
+ } catch {
107
+ return;
108
+ }
109
+ for (const entry of entries) {
110
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue;
111
+ const id = entry.name.slice(0, -6);
112
+ const sessionFile = path.join(dir, entry.name);
113
+ if (!registry.get(id)) {
114
+ registry.register({
115
+ id,
116
+ displayName: id,
117
+ kind: "sub",
118
+ parentId: parentId ?? MAIN_AGENT_ID,
119
+ session: null,
120
+ sessionFile,
121
+ status: "parked",
122
+ });
123
+ }
124
+ registerPersistedSubagentsFromDir(registry, path.join(dir, id), id);
125
+ }
126
+ }
127
+
84
128
  /** Guest-side proxy for hub actions executed on the collab host. */
85
129
  export interface AgentHubRemote {
86
130
  chat(id: string, text: string): void;
@@ -103,6 +147,22 @@ export interface AgentHubDeps {
103
147
  lifecycle?: AgentLifecycleManager;
104
148
  /** Injectable for tests; defaults to the process-global bus. */
105
149
  irc?: IrcBus;
150
+ /** TUI handle for transcript components; tests omit it and get a render-only stub. */
151
+ ui?: TUI;
152
+ /** Tool lookup for transcript renderers (labels, custom render functions). */
153
+ getTool?: (name: string) => AgentTool | undefined;
154
+ /** Extension message renderers for custom messages in the transcript. */
155
+ getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
156
+ /** Cwd used by tool renderers for path shortening; defaults to the project dir. */
157
+ cwd?: string;
158
+ /** Mirrors the main transcript's thinking-block visibility. */
159
+ hideThinkingBlock?: () => boolean;
160
+ /** Keys toggling tool output expansion (app.tools.expand). */
161
+ expandKeys?: KeyId[];
162
+ /** Focus the main view on this agent's live session (ctx.focusAgentSession). When absent (collab guest, tests), Enter opens the in-hub chat view instead. */
163
+ focusAgent?: (id: string) => Promise<void>;
164
+ /** Current main session file; used to seed parked historical subagents after restart. */
165
+ sessionFile?: string | null;
106
166
  /** Collab guest: route actions/transcripts to the host instead of local sessions. */
107
167
  remote?: AgentHubRemote;
108
168
  }
@@ -137,16 +197,33 @@ export class AgentHubOverlayComponent extends Container {
137
197
  #chatRefreshTimer: NodeJS.Timeout | undefined;
138
198
  #transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined;
139
199
 
140
- // Transcript viewer state (absorbed from the session observer overlay)
200
+ // Chat transcript: the same component renderers as the main session
201
+ // transcript, assembled incrementally from the persisted JSONL entries.
202
+ #ui: TUI;
203
+ #getTool: ((name: string) => AgentTool | undefined) | undefined;
204
+ #getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined;
205
+ #cwd: string;
206
+ #hideThinkingBlock: (() => boolean) | undefined;
207
+ #expandKeys: KeyId[];
208
+ #focusAgent: ((id: string) => Promise<void>) | undefined;
209
+ #chatLog = new TranscriptContainer();
210
+ #chatEntriesRef: SessionMessageEntry[] | undefined;
211
+ #chatBuiltCount = 0;
212
+ #chatPendingTools = new Map<string, ToolExecutionComponent | ReadToolGroupComponent>();
213
+ #chatReadArgs = new Map<string, Record<string, unknown>>();
214
+ #chatReadGroup: ReadToolGroupComponent | null = null;
215
+ #chatWaitingPoll: ToolExecutionComponent | null = null;
216
+ #chatExpandables: Array<{ setExpanded(expanded: boolean): void }> = [];
217
+ #chatExpanded = false;
218
+ #chatPlaceholder: string | undefined;
219
+
220
+ // Viewport state
141
221
  #scrollOffset = 0;
142
- #renderedLines: string[] = [];
222
+ #lastMaxScroll = 0;
143
223
  #viewportHeight = 20;
144
224
  #wasAtBottom = true;
145
- #viewerEntries: ViewerEntry[] = [];
146
- #selectedEntryIndex = 0;
147
- #expandedEntries = new Set<number>();
148
225
  #viewerHeaderLines: string[] = [];
149
- #mdTheme: MarkdownTheme = getMarkdownTheme();
226
+ #lastLeftTap = 0;
150
227
 
151
228
  constructor(deps: AgentHubDeps) {
152
229
  super();
@@ -160,6 +237,18 @@ export class AgentHubOverlayComponent extends Container {
160
237
  this.#requestRender = deps.requestRender;
161
238
  this.#hubKeys = deps.hubKeys;
162
239
  this.#remote = deps.remote;
240
+ this.#ui =
241
+ deps.ui ??
242
+ ({
243
+ requestRender: () => deps.requestRender(),
244
+ requestComponentRender: () => deps.requestRender(),
245
+ } as unknown as TUI);
246
+ this.#getTool = deps.getTool;
247
+ this.#getMessageRenderer = deps.getMessageRenderer;
248
+ this.#cwd = deps.cwd ?? getProjectDir();
249
+ this.#hideThinkingBlock = deps.hideThinkingBlock;
250
+ this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
251
+ this.#focusAgent = deps.focusAgent;
163
252
 
164
253
  this.#editor = new Editor(getEditorTheme());
165
254
  this.#editor.setMaxHeight(4);
@@ -170,6 +259,7 @@ export class AgentHubOverlayComponent extends Container {
170
259
  this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS);
171
260
  this.#ageTimer.unref?.();
172
261
 
262
+ if (!this.#remote) registerPersistedSubagents(this.#registry, deps.sessionFile);
173
263
  this.#refreshRows();
174
264
  }
175
265
 
@@ -185,6 +275,7 @@ export class AgentHubOverlayComponent extends Container {
185
275
  this.#chatRefreshTimer = undefined;
186
276
  }
187
277
  this.#detachLiveSession();
278
+ this.#resetChatLog();
188
279
  }
189
280
 
190
281
  override render(width: number): readonly string[] {
@@ -216,18 +307,13 @@ export class AgentHubOverlayComponent extends Container {
216
307
  this.#remoteTranscriptUnavailable = false;
217
308
  this.#remoteFetchInFlight = false;
218
309
  this.#remoteFetchToken++;
310
+ this.#resetChatLog();
219
311
  this.#scrollOffset = 0;
220
- this.#selectedEntryIndex = 0;
221
- this.#expandedEntries.clear();
222
312
  this.#wasAtBottom = true;
313
+ this.#lastLeftTap = 0;
223
314
  this.#editor.setText("");
224
315
  this.#attachLiveSession();
225
316
  this.#rebuildChatContent();
226
- // Auto-scroll to bottom and select last entry on open
227
- if (this.#viewerEntries.length > 0) {
228
- this.#selectedEntryIndex = this.#viewerEntries.length - 1;
229
- this.#rebuildChatContent();
230
- }
231
317
  this.#requestRender();
232
318
  }
233
319
 
@@ -283,12 +369,7 @@ export class AgentHubOverlayComponent extends Container {
283
369
  this.#chatRefreshTimer = setTimeout(() => {
284
370
  this.#chatRefreshTimer = undefined;
285
371
  if (this.#view !== "chat") return;
286
- // Keep auto-scrolling to bottom unless the user navigated away
287
- this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
288
372
  this.#rebuildChatContent();
289
- if (this.#wasAtBottom && this.#viewerEntries.length > 0) {
290
- this.#selectedEntryIndex = this.#viewerEntries.length - 1;
291
- }
292
373
  this.#requestRender();
293
374
  }, CHAT_REFRESH_DEBOUNCE_MS);
294
375
  this.#chatRefreshTimer.unref?.();
@@ -335,7 +416,7 @@ export class AgentHubOverlayComponent extends Container {
335
416
  lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`);
336
417
  }
337
418
  lines.push("");
338
- lines.push(` ${theme.fg("dim", "j/k:select Enter:chat r:revive x:kill Esc:close")}`);
419
+ lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`);
339
420
  lines.push(...new DynamicBorder().render(width));
340
421
  return lines;
341
422
  }
@@ -375,6 +456,16 @@ export class AgentHubOverlayComponent extends Container {
375
456
  this.#onDone();
376
457
  return;
377
458
  }
459
+ if (matchesKey(keyData, "left")) {
460
+ const now = Date.now();
461
+ if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
462
+ this.#lastLeftTap = 0;
463
+ this.#onDone();
464
+ } else {
465
+ this.#lastLeftTap = now;
466
+ }
467
+ return;
468
+ }
378
469
  if (keyData === "j" || matchesSelectDown(keyData)) {
379
470
  if (this.#rows.length > 0) {
380
471
  this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1);
@@ -391,7 +482,7 @@ export class AgentHubOverlayComponent extends Container {
391
482
  }
392
483
  if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
393
484
  const selected = this.#rows[this.#selectedRow];
394
- if (selected) this.openChat(selected.id);
485
+ if (selected) this.#activateAgent(selected);
395
486
  return;
396
487
  }
397
488
  if (keyData === "r") {
@@ -404,6 +495,30 @@ export class AgentHubOverlayComponent extends Container {
404
495
  }
405
496
  }
406
497
 
498
+ /**
499
+ * Enter on a row: focus the main view on the agent's live session and close
500
+ * the hub. The transcript then renders through the regular session pipeline —
501
+ * exact parity by construction. Collab guests (no local sessions) keep the
502
+ * in-hub chat view.
503
+ */
504
+ #activateAgent(ref: AgentRef): void {
505
+ this.#notice = undefined;
506
+ const focusAgent = this.#focusAgent;
507
+ if (this.#remote || !focusAgent) {
508
+ this.openChat(ref.id);
509
+ return;
510
+ }
511
+ void (async () => {
512
+ try {
513
+ await focusAgent(ref.id); // ensureLive inside revives parked agents; no parking, no session files
514
+ this.#onDone();
515
+ } catch (error) {
516
+ this.#notice = error instanceof Error ? error.message : String(error);
517
+ this.#requestRender();
518
+ }
519
+ })();
520
+ }
521
+
407
522
  #reviveSelected(): void {
408
523
  const ref = this.#rows[this.#selectedRow];
409
524
  if (!ref) return;
@@ -471,7 +586,12 @@ export class AgentHubOverlayComponent extends Container {
471
586
  const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1;
472
587
  this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
473
588
 
474
- const maxScroll = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
589
+ const contentLines: readonly string[] = this.#chatPlaceholder
590
+ ? [theme.fg("dim", this.#chatPlaceholder)]
591
+ : this.#chatLog.render(innerWidth);
592
+
593
+ const maxScroll = Math.max(0, contentLines.length - this.#viewportHeight);
594
+ this.#lastMaxScroll = maxScroll;
475
595
  if (this.#wasAtBottom) this.#scrollOffset = maxScroll;
476
596
  this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
477
597
 
@@ -483,11 +603,11 @@ export class AgentHubOverlayComponent extends Container {
483
603
  lines.push(...new DynamicBorder().render(width));
484
604
 
485
605
  const scrollView = new ScrollView(
486
- this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
606
+ contentLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
487
607
  {
488
608
  height: this.#viewportHeight,
489
609
  scrollbar: "auto",
490
- totalRows: this.#renderedLines.length,
610
+ totalRows: contentLines.length,
491
611
  theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
492
612
  },
493
613
  );
@@ -506,7 +626,9 @@ export class AgentHubOverlayComponent extends Container {
506
626
  const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined;
507
627
  const statsLine = this.#buildStatsLine(observed);
508
628
  if (statsLine) lines.push(` ${statsLine}`);
509
- lines.push(` ${theme.fg("dim", "Enter:send Esc:back empty input: j/k:scroll Enter:expand g/G:top/bottom")}`);
629
+ lines.push(
630
+ ` ${theme.fg("dim", `Enter:send Esc:back ←←:parent ${this.#expandKeys[0] ?? "ctrl+o"}:expand empty input: j/k:scroll g/G:top/bottom`)}`,
631
+ );
510
632
  return lines;
511
633
  }
512
634
 
@@ -538,7 +660,7 @@ export class AgentHubOverlayComponent extends Container {
538
660
  return parts.join(theme.sep.dot);
539
661
  }
540
662
 
541
- /** Rebuild the chat header + transcript content lines */
663
+ /** Rebuild the chat header and sync transcript components from new entries */
542
664
  #rebuildChatContent(): void {
543
665
  const id = this.#chatAgentId;
544
666
  const ref = id ? this.#registry.get(id) : undefined;
@@ -562,26 +684,24 @@ export class AgentHubOverlayComponent extends Container {
562
684
  this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`);
563
685
  }
564
686
 
565
- const contentLines: string[] = [];
566
- this.#viewerEntries = [];
567
687
  if (!ref) {
568
- contentLines.push(theme.fg("dim", "Agent no longer registered."));
688
+ this.#chatPlaceholder = "Agent no longer registered.";
569
689
  } else if (!this.#remote && !ref.sessionFile) {
570
- contentLines.push(theme.fg("dim", "No session file available yet."));
690
+ this.#chatPlaceholder = "No session file available yet.";
571
691
  } else if (!messageEntries) {
572
- contentLines.push(theme.fg("dim", "Unable to read session file."));
692
+ this.#chatPlaceholder = "Unable to read session file.";
573
693
  } else if (messageEntries.length === 0) {
574
694
  if (this.#remote && this.#remoteTranscriptUnavailable) {
575
- contentLines.push(theme.fg("dim", "Transcript lives on the host — not available."));
695
+ this.#chatPlaceholder = "Transcript lives on the host — not available.";
576
696
  } else if (this.#remote && !this.#transcriptCache) {
577
- contentLines.push(theme.fg("dim", "Loading transcript from host…"));
697
+ this.#chatPlaceholder = "Loading transcript from host…";
578
698
  } else {
579
- contentLines.push(theme.fg("dim", "No messages yet."));
699
+ this.#chatPlaceholder = "No messages yet.";
580
700
  }
581
701
  } else {
582
- this.#buildTranscriptLines(messageEntries, contentLines);
702
+ this.#chatPlaceholder = undefined;
703
+ this.#syncChatComponents(messageEntries);
583
704
  }
584
- this.#renderedLines = contentLines;
585
705
  }
586
706
 
587
707
  #handleChatInput(keyData: string): void {
@@ -597,8 +717,31 @@ export class AgentHubOverlayComponent extends Container {
597
717
  return;
598
718
  }
599
719
 
600
- // Navigation mirrors the old observer overlay while the input is empty;
601
- // once the user starts typing, the editor owns every key.
720
+ // Tool output expansion mirrors the main transcript's app.tools.expand toggle.
721
+ for (const key of this.#expandKeys) {
722
+ if (matchesKey(keyData, key)) {
723
+ this.#chatExpanded = !this.#chatExpanded;
724
+ for (const component of this.#chatExpandables) component.setExpanded(this.#chatExpanded);
725
+ this.#requestRender();
726
+ return;
727
+ }
728
+ }
729
+
730
+ // Double-tap left on an empty editor hops to the parent session —
731
+ // the inverse of the main editor's double-left opening the hub.
732
+ if (editorEmpty && matchesKey(keyData, "left")) {
733
+ const now = Date.now();
734
+ if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
735
+ this.#lastLeftTap = 0;
736
+ this.#openParent();
737
+ } else {
738
+ this.#lastLeftTap = now;
739
+ }
740
+ return;
741
+ }
742
+
743
+ // Scrolling works while the input is empty; once the user starts
744
+ // typing, the editor owns every key.
602
745
  if (editorEmpty && this.#handleViewerNavigation(keyData)) {
603
746
  return;
604
747
  }
@@ -607,11 +750,23 @@ export class AgentHubOverlayComponent extends Container {
607
750
  this.#requestRender();
608
751
  }
609
752
 
753
+ /** Open the chat for the agent's parent, or close the hub when the parent is the main session. */
754
+ #openParent(): void {
755
+ const ref = this.#chatAgentId ? this.#registry.get(this.#chatAgentId) : undefined;
756
+ const parentId = ref?.parentId;
757
+ if (parentId && parentId !== MAIN_AGENT_ID && this.#registry.get(parentId)) {
758
+ this.openChat(parentId);
759
+ return;
760
+ }
761
+ this.#onDone();
762
+ }
763
+
610
764
  #closeChat(): void {
611
765
  this.#view = "table";
612
766
  this.#chatAgentId = undefined;
613
767
  this.#notice = undefined;
614
768
  this.#detachLiveSession();
769
+ this.#resetChatLog();
615
770
  this.#refreshRows();
616
771
  this.#requestRender();
617
772
  }
@@ -644,415 +799,391 @@ export class AgentHubOverlayComponent extends Container {
644
799
  this.#requestRender();
645
800
  }
646
801
 
647
- /** Viewer navigation (selection, paging, expand) for the chat transcript. Returns true when handled. */
802
+ /** Viewport scrolling for the chat transcript. Returns true when handled. */
648
803
  #handleViewerNavigation(keyData: string): boolean {
649
- const entryCount = this.#viewerEntries.length;
650
-
804
+ const maxScroll = this.#lastMaxScroll;
805
+ const scrollBy = (delta: number) => {
806
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset + delta, maxScroll));
807
+ this.#wasAtBottom = this.#scrollOffset >= maxScroll;
808
+ this.#requestRender();
809
+ };
651
810
  if (keyData === "j" || matchesSelectDown(keyData)) {
652
- if (entryCount > 0) {
653
- this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
654
- }
655
- this.#rebuildAndScroll();
811
+ scrollBy(1);
656
812
  return true;
657
813
  }
658
814
  if (keyData === "k" || matchesSelectUp(keyData)) {
659
- if (entryCount > 0) {
660
- this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
661
- }
662
- this.#rebuildAndScroll();
815
+ scrollBy(-1);
663
816
  return true;
664
817
  }
665
818
  if (matchesKey(keyData, "pageDown")) {
666
- if (entryCount > 0) {
667
- const prevIndex = this.#selectedEntryIndex;
668
- this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
669
- if (this.#selectedEntryIndex === prevIndex) {
670
- this.#scrollOffset = Math.min(
671
- this.#scrollOffset + PAGE_SIZE,
672
- Math.max(0, this.#renderedLines.length - this.#viewportHeight),
673
- );
674
- }
675
- } else {
676
- this.#scrollOffset = Math.min(
677
- this.#scrollOffset + PAGE_SIZE,
678
- Math.max(0, this.#renderedLines.length - this.#viewportHeight),
679
- );
680
- }
681
- this.#rebuildAndScroll();
819
+ scrollBy(PAGE_SIZE);
682
820
  return true;
683
821
  }
684
822
  if (matchesKey(keyData, "pageUp")) {
685
- if (entryCount > 0) {
686
- const prevIndex = this.#selectedEntryIndex;
687
- this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
688
- if (this.#selectedEntryIndex === prevIndex) {
689
- this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
690
- }
691
- } else {
692
- this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
693
- }
694
- this.#rebuildAndScroll();
695
- return true;
696
- }
697
- if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
698
- if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
699
- if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
700
- this.#expandedEntries.delete(this.#selectedEntryIndex);
701
- } else {
702
- this.#expandedEntries.add(this.#selectedEntryIndex);
703
- }
704
- this.#rebuildAndScroll();
705
- }
823
+ scrollBy(-PAGE_SIZE);
706
824
  return true;
707
825
  }
708
826
  if (keyData === "G") {
709
- if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
710
- this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
711
- this.#rebuildAndScroll();
827
+ this.#scrollOffset = maxScroll;
828
+ this.#wasAtBottom = true;
829
+ this.#requestRender();
712
830
  return true;
713
831
  }
714
832
  if (keyData === "g") {
715
- this.#selectedEntryIndex = 0;
716
833
  this.#scrollOffset = 0;
717
- this.#rebuildAndScroll();
834
+ this.#wasAtBottom = maxScroll === 0;
835
+ this.#requestRender();
718
836
  return true;
719
837
  }
720
838
  return false;
721
839
  }
722
840
 
723
- /** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
724
- #rebuildAndScroll(): void {
725
- // Resume auto-scrolling once selection returns to the last entry
726
- this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
727
- this.#rebuildChatContent();
728
- this.#scrollToSelectedEntry();
729
- this.#requestRender();
730
- }
731
-
732
- #scrollToSelectedEntry(): void {
733
- if (this.#viewerEntries.length === 0) return;
734
- const entry = this.#viewerEntries[this.#selectedEntryIndex];
735
- if (!entry) return;
841
+ // ========================================================================
842
+ // Transcript assembly — the same components as the main session transcript
843
+ // (mirrors UiHelpers.renderSessionContext / addMessageToChat).
844
+ // ========================================================================
736
845
 
737
- const entryTop = entry.lineStart;
738
- const entryBottom = entry.lineStart + entry.lineCount;
846
+ /** Tear down transcript components (sealing pending spinners) and reset build state. */
847
+ #resetChatLog(): void {
848
+ for (const pending of this.#chatPendingTools.values()) pending.seal();
849
+ this.#chatPendingTools.clear();
850
+ this.#chatReadArgs.clear();
851
+ this.#chatReadGroup = null;
852
+ this.#chatWaitingPoll = null;
853
+ this.#chatExpandables = [];
854
+ this.#chatLog.dispose();
855
+ this.#chatLog.clear();
856
+ this.#chatEntriesRef = undefined;
857
+ this.#chatBuiltCount = 0;
858
+ this.#chatPlaceholder = undefined;
859
+ }
739
860
 
740
- if (entry.lineCount >= this.#viewportHeight) {
741
- // Entry taller than viewport: only snap when it's completely out of view.
742
- if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
743
- this.#scrollOffset = Math.max(0, entryTop - 1);
744
- } else if (this.#scrollOffset >= entryBottom) {
745
- this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
746
- }
747
- } else {
748
- // Entry fits in viewport: ensure it's fully visible
749
- if (entryTop < this.#scrollOffset) {
750
- this.#scrollOffset = Math.max(0, entryTop - 1);
751
- }
752
- if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
753
- this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
754
- }
861
+ /** Append components for entries not yet materialized. Rebuilds from scratch when the cache was replaced (agent switch, file rotation). */
862
+ #syncChatComponents(entries: SessionMessageEntry[]): void {
863
+ if (this.#chatEntriesRef !== entries) {
864
+ this.#resetChatLog();
865
+ this.#chatEntriesRef = entries;
755
866
  }
867
+ for (let i = this.#chatBuiltCount; i < entries.length; i++) {
868
+ this.#appendChatMessage(entries[i].message);
869
+ }
870
+ this.#chatBuiltCount = entries.length;
756
871
  }
757
872
 
758
- // ========================================================================
759
- // Transcript rendering (absorbed from the session observer overlay)
760
- // ========================================================================
761
-
762
- #buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void {
763
- // Build a tool call ID -> tool result map
764
- const toolResults = new Map<string, ToolResultMessage>();
765
- for (const entry of messageEntries) {
766
- if (entry.message.role === "toolResult") {
767
- toolResults.set(entry.message.toolCallId, entry.message);
768
- }
769
- }
873
+ #trackExpandable(component: { setExpanded(expanded: boolean): void }): void {
874
+ component.setExpanded(this.#chatExpanded);
875
+ this.#chatExpandables.push(component);
876
+ }
770
877
 
771
- let entryIndex = 0;
772
- for (const entry of messageEntries) {
773
- const msg = entry.message;
774
-
775
- if (msg.role === "assistant") {
776
- // Handle error messages with empty content
777
- if (msg.content.length === 0 && msg.errorMessage && !isSilentAbort(msg.errorMessage)) {
778
- const startLine = lines.length;
779
- const isSelected = entryIndex === this.#selectedEntryIndex;
780
- const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " ";
781
- lines.push("");
782
- const errorLines = msg.errorMessage.split("\n");
783
- const maxWidth = contentWidth();
784
- lines.push(`${cursor} ${theme.fg("error", `✗ Error: ${sanitizeLine(errorLines[0], maxWidth)}`)}`);
785
- for (let i = 1; i < errorLines.length; i++) {
786
- lines.push(`${INDENT}${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
787
- }
788
- this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "text" });
789
- entryIndex++;
790
- } else {
791
- for (const content of msg.content) {
792
- if (content.type === "thinking" && content.thinking.trim()) {
793
- const startLine = lines.length;
794
- const isExpanded = this.#expandedEntries.has(entryIndex);
795
- const isSelected = entryIndex === this.#selectedEntryIndex;
796
- this.#renderThinkingLines(lines, content.thinking.trim(), isExpanded, isSelected);
797
- this.#viewerEntries.push({
798
- lineStart: startLine,
799
- lineCount: lines.length - startLine,
800
- kind: "thinking",
801
- });
802
- entryIndex++;
803
- } else if (content.type === "text" && content.text.trim()) {
804
- const startLine = lines.length;
805
- const isExpanded = this.#expandedEntries.has(entryIndex);
806
- const isSelected = entryIndex === this.#selectedEntryIndex;
807
- this.#renderTextLines(lines, content.text.trim(), isExpanded, isSelected);
808
- this.#viewerEntries.push({
809
- lineStart: startLine,
810
- lineCount: lines.length - startLine,
811
- kind: "text",
812
- });
813
- entryIndex++;
814
- } else if (content.type === "toolCall") {
815
- const startLine = lines.length;
816
- const isExpanded = this.#expandedEntries.has(entryIndex);
817
- const isSelected = entryIndex === this.#selectedEntryIndex;
818
- const result = toolResults.get(content.id);
819
- this.#renderToolCallLines(lines, content, result, isExpanded, isSelected);
820
- this.#viewerEntries.push({
821
- lineStart: startLine,
822
- lineCount: lines.length - startLine,
823
- kind: "toolCall",
824
- });
825
- entryIndex++;
826
- }
827
- }
828
- }
829
- } else if (msg.role === "user" || msg.role === "developer") {
830
- const text =
831
- typeof msg.content === "string"
832
- ? msg.content
833
- : msg.content
834
- .filter((b): b is { type: "text"; text: string } => b.type === "text")
835
- .map(b => b.text)
836
- .join("\n");
837
- if (text.trim()) {
838
- const startLine = lines.length;
839
- const isSelected = entryIndex === this.#selectedEntryIndex;
840
- const isExpanded = this.#expandedEntries.has(entryIndex);
841
- const label = msg.role === "developer" ? "System" : "User";
842
- const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " ";
843
- lines.push("");
844
- if (isExpanded) {
845
- lines.push(`${cursor} ${theme.fg("dim", `[${label}]`)}`);
846
- const mdLines = this.#renderMarkdownToLines(text.trim());
847
- for (const ml of mdLines) {
848
- lines.push(ml);
849
- }
850
- } else {
851
- const firstLine = text.trim().split("\n")[0];
852
- const totalLines = text.trim().split("\n").length;
853
- const hint = totalLines > 1 ? theme.fg("dim", ` (${totalLines} lines)`) : "";
854
- lines.push(
855
- `${cursor} ${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", sanitizeLine(firstLine, TRUNCATE_LENGTHS.TITLE))}${hint}`,
856
- );
857
- }
858
- this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "user" });
859
- entryIndex++;
860
- }
861
- }
878
+ /** A `job` poll showing all-running is displaced by the next `job` call (mirrors the rebuild path). */
879
+ #resolveWaitingPoll(nextToolName?: string): void {
880
+ const previous = this.#chatWaitingPoll;
881
+ if (!previous) return;
882
+ this.#chatWaitingPoll = null;
883
+ if (nextToolName === "job" && previous.isDisplaceableBlock()) {
884
+ this.#chatLog.removeChild(previous);
862
885
  }
886
+ previous.seal();
863
887
  }
864
888
 
865
- /** Render markdown text into indented lines using the theme's markdown renderer */
866
- #renderMarkdownToLines(text: string, indent: string = INDENT): string[] {
867
- const width = Math.max(40, (process.stdout.columns || 80) - indent.length - 4);
868
- const md = new Markdown(text, 0, 0, this.#mdTheme);
869
- const rendered = md.render(width);
870
- return rendered.map(line => `${indent}${line.trimEnd()}`);
889
+ #ensureReadGroup(): ReadToolGroupComponent {
890
+ if (!this.#chatReadGroup) {
891
+ this.#chatReadGroup = new ReadToolGroupComponent({
892
+ showContentPreview: settings.get("read.toolResultPreview"),
893
+ });
894
+ this.#trackExpandable(this.#chatReadGroup);
895
+ this.#chatLog.addChild(this.#chatReadGroup);
896
+ }
897
+ return this.#chatReadGroup;
871
898
  }
872
899
 
873
- #renderThinkingLines(lines: string[], thinking: string, expanded: boolean, selected: boolean): void {
874
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
875
- const maxChars = expanded ? MAX_THINKING_CHARS_EXPANDED : MAX_THINKING_CHARS_COLLAPSED;
876
- const truncated = thinking.length > maxChars;
877
- const expandLabel = !expanded && truncated ? theme.fg("dim", " ↵") : "";
878
-
879
- lines.push("");
880
- lines.push(`${cursor} ${theme.fg("dim", "💭 Thinking")}${expandLabel}`);
881
-
882
- const displayText = truncated ? `${thinking.slice(0, maxChars)}...` : thinking;
883
- if (expanded) {
884
- // Expanded thinking: render as markdown for readable formatting
885
- const mdLines = this.#renderMarkdownToLines(displayText);
886
- const maxLines = 100;
887
- for (let i = 0; i < Math.min(mdLines.length, maxLines); i++) {
888
- lines.push(mdLines[i]);
900
+ #appendChatMessage(message: AgentMessage): void {
901
+ switch (message.role) {
902
+ case "assistant":
903
+ this.#appendAssistantMessage(message);
904
+ break;
905
+ case "toolResult":
906
+ this.#appendToolResult(message);
907
+ break;
908
+ case "user":
909
+ case "developer": {
910
+ // A user prompt closes the poll-displacement window, same as the live path.
911
+ if (message.role === "user") this.#resolveWaitingPoll();
912
+ const textContent =
913
+ message.role !== "user"
914
+ ? ""
915
+ : typeof message.content === "string"
916
+ ? message.content
917
+ : message.content
918
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
919
+ .map(block => block.text)
920
+ .join("");
921
+ if (textContent) {
922
+ const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
923
+ this.#chatLog.addChild(new UserMessageComponent(textContent, isSynthetic));
924
+ }
925
+ break;
889
926
  }
890
- if (mdLines.length > maxLines) {
891
- lines.push(`${INDENT}${theme.fg("dim", `... ${mdLines.length - maxLines} more lines`)}`);
927
+ case "bashExecution": {
928
+ const component = new BashExecutionComponent(message.command, this.#ui, message.excludeFromContext);
929
+ if (message.output) component.appendOutput(message.output);
930
+ component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
931
+ this.#chatLog.addChild(component);
932
+ break;
892
933
  }
893
- } else {
894
- // Collapsed thinking: brief italic preview
895
- const thinkingLines = displayText.split("\n");
896
- const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
897
- for (let i = 0; i < Math.min(thinkingLines.length, maxLines); i++) {
898
- lines.push(`${INDENT}${theme.fg("thinkingText", sanitizeLine(thinkingLines[i]))}`);
934
+ case "pythonExecution": {
935
+ const component = new EvalExecutionComponent(message.code, this.#ui, message.excludeFromContext);
936
+ if (message.output) component.appendOutput(message.output);
937
+ component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
938
+ this.#chatLog.addChild(component);
939
+ break;
899
940
  }
900
- if (thinkingLines.length > maxLines) {
901
- lines.push(`${INDENT}${theme.fg("dim", `... ${thinkingLines.length - maxLines} more lines`)}`);
941
+ case "hookMessage":
942
+ case "custom":
943
+ this.#appendCustomMessage(message);
944
+ break;
945
+ case "compactionSummary": {
946
+ const component = new CompactionSummaryMessageComponent(message);
947
+ this.#trackExpandable(component);
948
+ this.#chatLog.addChild(component);
949
+ break;
902
950
  }
951
+ case "branchSummary": {
952
+ const component = new BranchSummaryMessageComponent(message);
953
+ this.#trackExpandable(component);
954
+ this.#chatLog.addChild(component);
955
+ break;
956
+ }
957
+ case "fileMention": {
958
+ const block = new TranscriptBlock();
959
+ for (const file of message.files) {
960
+ let suffix: string;
961
+ if (file.skippedReason === "tooLarge") {
962
+ const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
963
+ suffix = `(skipped: ${size})`;
964
+ } else {
965
+ suffix = file.image
966
+ ? "(image)"
967
+ : file.lineCount === undefined
968
+ ? "(unknown lines)"
969
+ : `(${file.lineCount} lines)`;
970
+ }
971
+ const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
972
+ "accent",
973
+ file.path,
974
+ )} ${theme.fg("dim", suffix)}`;
975
+ block.addChild(new Text(text, 0, 0));
976
+ }
977
+ if (block.children.length > 0) this.#chatLog.addChild(block);
978
+ break;
979
+ }
980
+ default:
981
+ message satisfies never;
903
982
  }
904
983
  }
905
984
 
906
- #renderTextLines(lines: string[], text: string, expanded: boolean, selected: boolean): void {
907
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
985
+ #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
986
+ const assistantComponent = new AssistantMessageComponent(message, this.#hideThinkingBlock?.() ?? false, () =>
987
+ this.#requestRender(),
988
+ );
989
+ assistantComponent.setUsageInfo(message.usage);
990
+ this.#chatLog.addChild(assistantComponent);
908
991
 
909
- lines.push("");
910
- lines.push(`${cursor} ${theme.fg("muted", "Response")}`);
992
+ const hasVisibleAssistantContent = message.content.some(
993
+ content =>
994
+ (content.type === "text" && content.text.trim().length > 0) ||
995
+ (content.type === "thinking" && content.thinking.trim().length > 0),
996
+ );
997
+ if (hasVisibleAssistantContent) {
998
+ // New visible turn content closes the current read run (mirrors rebuild).
999
+ this.#chatReadGroup?.seal();
1000
+ this.#chatReadGroup = null;
1001
+ }
911
1002
 
912
- if (expanded) {
913
- // Expanded: full markdown rendering
914
- const mdLines = this.#renderMarkdownToLines(text);
915
- for (const ml of mdLines) {
916
- lines.push(ml);
917
- }
918
- } else {
919
- // Collapsed: first few lines as plain text
920
- const textLines = text.split("\n");
921
- const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
922
- const maxWidth = contentWidth();
923
- for (let i = 0; i < Math.min(textLines.length, maxLines); i++) {
924
- lines.push(`${INDENT}${sanitizeLine(textLines[i], maxWidth)}`);
1003
+ const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
1004
+ const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
1005
+ const errorMessage = hasErrorStop
1006
+ ? message.stopReason === "aborted"
1007
+ ? resolveAbortLabel(message.errorMessage)
1008
+ : message.errorMessage || "Error"
1009
+ : null;
1010
+
1011
+ for (const content of message.content) {
1012
+ if (content.type !== "toolCall") continue;
1013
+ this.#resolveWaitingPoll(content.name);
1014
+
1015
+ if (
1016
+ content.name === "read" &&
1017
+ readArgsHaveTarget(content.arguments) &&
1018
+ !readArgsTargetInternalUrl(content.arguments)
1019
+ ) {
1020
+ if (hasErrorStop && errorMessage) {
1021
+ const group = this.#ensureReadGroup();
1022
+ group.updateArgs(content.arguments, content.id);
1023
+ group.updateResult(
1024
+ { content: [{ type: "text", text: errorMessage }], isError: true },
1025
+ false,
1026
+ content.id,
1027
+ );
1028
+ } else {
1029
+ const normalizedArgs =
1030
+ content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
1031
+ ? (content.arguments as Record<string, unknown>)
1032
+ : {};
1033
+ this.#chatReadArgs.set(content.id, normalizedArgs);
1034
+ }
1035
+ continue;
925
1036
  }
926
- if (textLines.length > maxLines) {
927
- lines.push(`${INDENT}${theme.fg("dim", `... ${textLines.length - maxLines} more lines`)}`);
1037
+
1038
+ this.#chatReadGroup?.seal();
1039
+ this.#chatReadGroup = null;
1040
+ const component = new ToolExecutionComponent(
1041
+ content.name,
1042
+ content.arguments,
1043
+ {
1044
+ // Images can't be sliced through the scroll viewport; keep them off.
1045
+ showImages: false,
1046
+ editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
1047
+ editAllowFuzzy: settings.get("edit.fuzzyMatch"),
1048
+ liveRegion: this.#chatLog,
1049
+ },
1050
+ this.#getTool?.(content.name),
1051
+ this.#ui,
1052
+ this.#cwd,
1053
+ content.id,
1054
+ );
1055
+ this.#trackExpandable(component);
1056
+ this.#chatLog.addChild(component);
1057
+
1058
+ if (hasErrorStop && errorMessage) {
1059
+ component.updateResult(
1060
+ { content: [{ type: "text", text: errorMessage }], isError: true },
1061
+ false,
1062
+ content.id,
1063
+ );
1064
+ } else {
1065
+ this.#chatPendingTools.set(content.id, component);
928
1066
  }
929
1067
  }
930
1068
  }
931
1069
 
932
- #renderToolCallLines(
933
- lines: string[],
934
- call: { id: string; name: string; arguments: Record<string, unknown>; intent?: string },
935
- result: ToolResultMessage | undefined,
936
- expanded: boolean,
937
- selected: boolean,
938
- ): void {
939
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
940
- lines.push("");
941
-
942
- // Tool call header
943
- const intentStr = call.intent ? theme.fg("dim", ` ${sanitizeLine(call.intent, TRUNCATE_LENGTHS.SHORT)}`) : "";
944
- lines.push(`${cursor} ${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
945
-
946
- // Key arguments
947
- const argSummary = this.#formatToolArgs(call.name, call.arguments);
948
- if (argSummary) {
949
- lines.push(`${INDENT}${theme.fg("dim", sanitizeLine(argSummary, contentWidth()))}`);
1070
+ #appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
1071
+ const pending = this.#chatPendingTools.get(message.toolCallId);
1072
+ const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent);
1073
+ if (isReadGroupResult) {
1074
+ let component = pending;
1075
+ if (!component) {
1076
+ const group = this.#ensureReadGroup();
1077
+ const args = this.#chatReadArgs.get(message.toolCallId);
1078
+ if (args) group.updateArgs(args, message.toolCallId);
1079
+ component = group;
1080
+ }
1081
+ component.updateResult(message, false, message.toolCallId);
1082
+ this.#chatPendingTools.delete(message.toolCallId);
1083
+ this.#chatReadArgs.delete(message.toolCallId);
1084
+ return;
950
1085
  }
951
-
952
- // Tool result
953
- if (result) {
954
- this.#renderToolResultLines(lines, result, expanded);
1086
+ if (!pending) return;
1087
+ pending.updateResult(message, false, message.toolCallId);
1088
+ this.#chatPendingTools.delete(message.toolCallId);
1089
+ if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
1090
+ this.#chatWaitingPoll = pending;
955
1091
  }
956
1092
  }
957
1093
 
958
- #renderToolResultLines(lines: string[], result: ToolResultMessage, expanded: boolean): void {
959
- const textParts = result.content
960
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
961
- .map(p => p.text);
962
- const text = textParts.join("\n").trim();
963
-
964
- if (result.isError) {
965
- const errorLines = text.split("\n");
966
- const maxErrorLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
967
- const maxWidth = contentWidth();
968
- lines.push(`${INDENT}${theme.fg("error", `✗ ${sanitizeLine(errorLines[0] || "Error", maxWidth)}`)}`);
969
- for (let i = 1; i < Math.min(errorLines.length, maxErrorLines); i++) {
970
- lines.push(`${INDENT} ${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
971
- }
972
- if (errorLines.length > maxErrorLines) {
973
- lines.push(`${INDENT} ${theme.fg("dim", `... ${errorLines.length - maxErrorLines} more lines`)}`);
1094
+ #appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
1095
+ if (!message.display) return;
1096
+ if (message.customType === "async-result") {
1097
+ const details = (
1098
+ message as CustomMessage<{
1099
+ jobId?: string;
1100
+ type?: "bash" | "task";
1101
+ label?: string;
1102
+ durationMs?: number;
1103
+ jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
1104
+ }>
1105
+ ).details;
1106
+ const jobs =
1107
+ details?.jobs && details.jobs.length > 0
1108
+ ? details.jobs
1109
+ : [
1110
+ {
1111
+ jobId: details?.jobId,
1112
+ type: details?.type,
1113
+ label: details?.label,
1114
+ durationMs: details?.durationMs,
1115
+ },
1116
+ ];
1117
+ const block = new TranscriptBlock();
1118
+ for (const job of jobs) {
1119
+ const jobId = job.jobId ?? "unknown";
1120
+ const typeLabel = job.type ? `[${job.type}]` : "[job]";
1121
+ const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
1122
+ const line = [
1123
+ theme.fg("success", `${theme.status.done} Background job completed`),
1124
+ theme.fg("dim", typeLabel),
1125
+ theme.fg("accent", jobId),
1126
+ duration ? theme.fg("dim", `(${duration})`) : undefined,
1127
+ ]
1128
+ .filter(Boolean)
1129
+ .join(" ");
1130
+ block.addChild(new Text(line, 1, 0));
974
1131
  }
1132
+ this.#chatLog.addChild(block);
975
1133
  return;
976
1134
  }
977
-
978
- if (!text) {
979
- lines.push(`${INDENT}${theme.fg("dim", "✓ done")}`);
1135
+ if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
1136
+ const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details;
1137
+ const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
1138
+ this.#trackExpandable(component);
1139
+ this.#chatLog.addChild(component);
980
1140
  return;
981
1141
  }
982
-
983
- const resultLines = text.split("\n");
984
- const maxLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.OUTPUT_COLLAPSED;
985
-
986
- // Status line
987
- const statusPrefix = `${INDENT}${theme.fg("success", "✓")}`;
988
-
989
- if (resultLines.length === 1 && text.length < TRUNCATE_LENGTHS.LONG) {
990
- lines.push(`${statusPrefix} ${theme.fg("dim", sanitizeLine(text))}`);
1142
+ if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
1143
+ this.#chatLog.addChild(new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>));
991
1144
  return;
992
1145
  }
993
-
994
- lines.push(`${statusPrefix} ${theme.fg("dim", `${resultLines.length} lines`)}`);
995
- const displayLines = resultLines.slice(0, maxLines);
996
- for (const rl of displayLines) {
997
- lines.push(`${INDENT} ${theme.fg("dim", sanitizeLine(rl))}`);
998
- }
999
- if (resultLines.length > maxLines) {
1000
- lines.push(`${INDENT} ${theme.fg("dim", `... ${resultLines.length - maxLines} more`)}`);
1146
+ if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
1147
+ const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
1148
+ this.#trackExpandable(component);
1149
+ this.#chatLog.addChild(component);
1150
+ return;
1001
1151
  }
1002
- }
1003
-
1004
- #formatToolArgs(toolName: string, args: Record<string, unknown>): string {
1005
- switch (toolName) {
1006
- case "read":
1007
- case "write":
1008
- case "edit":
1009
- return args.path ? `path: ${args.path}` : "";
1010
- case "search": {
1011
- const searchPathsInput =
1012
- typeof args.paths === "string" || Array.isArray(args.paths)
1013
- ? args.paths
1014
- : typeof args.path === "string"
1015
- ? args.path
1016
- : undefined;
1017
- const searchPaths = toPathList(searchPathsInput);
1018
- return [
1019
- args.pattern ? `pattern: ${args.pattern}` : "",
1020
- searchPaths.length > 0 ? `paths: ${searchPaths.join(", ")}` : "",
1021
- ]
1022
- .filter(Boolean)
1023
- .join(", ");
1024
- }
1025
- case "find":
1026
- return Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "";
1027
- case "bash": {
1028
- const cmd = args.command;
1029
- return typeof cmd === "string" ? replaceTabs(cmd) : "";
1030
- }
1031
- case "lsp":
1032
- return [args.action, args.file, args.symbol].filter(Boolean).join(" ");
1033
- case "ast_grep":
1034
- case "ast_edit":
1035
- return args.path ? `path: ${args.path}` : "";
1036
- case "task": {
1037
- const target = typeof args.agent === "string" ? args.agent : "";
1038
- const id = typeof args.id === "string" && args.id ? ` ${args.id}` : "";
1039
- return `${target}${id}`.trim();
1040
- }
1041
- default: {
1042
- const parts: string[] = [];
1043
- let total = 0;
1044
- for (const key in args) {
1045
- if (key.startsWith("_")) continue;
1046
- const value = args[key];
1047
- const v = typeof value === "string" ? value : JSON.stringify(value);
1048
- const entry = `${key}: ${replaceTabs(v ?? "")}`;
1049
- if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
1050
- parts.push(entry);
1051
- total += entry.length;
1052
- }
1053
- return parts.join(", ");
1054
- }
1152
+ if (
1153
+ message.customType === "irc:incoming" ||
1154
+ message.customType === "irc:autoreply" ||
1155
+ message.customType === "irc:relay"
1156
+ ) {
1157
+ const details = (
1158
+ message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
1159
+ ).details;
1160
+ const kind =
1161
+ message.customType === "irc:incoming"
1162
+ ? ("incoming" as const)
1163
+ : message.customType === "irc:autoreply"
1164
+ ? ("autoreply" as const)
1165
+ : ("relay" as const);
1166
+ const card = createIrcMessageCard(
1167
+ {
1168
+ kind,
1169
+ from: details?.from,
1170
+ to: details?.to,
1171
+ body: kind === "incoming" ? details?.message : details?.body,
1172
+ replyTo: details?.replyTo,
1173
+ timestamp: message.timestamp,
1174
+ },
1175
+ () => this.#chatExpanded,
1176
+ theme,
1177
+ );
1178
+ this.#chatLog.addChild(card);
1179
+ return;
1055
1180
  }
1181
+ const component = new CustomMessageComponent(
1182
+ message as CustomMessage<unknown>,
1183
+ this.#getMessageRenderer?.(message.customType),
1184
+ );
1185
+ this.#trackExpandable(component);
1186
+ this.#chatLog.addChild(component);
1056
1187
  }
1057
1188
 
1058
1189
  #loadTranscript(sessionFile: string): SessionMessageEntry[] | null {