@bastani/atomic 0.8.1 → 0.8.2-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 (149) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/builtin/intercom/config.ts +3 -4
  3. package/dist/builtin/intercom/index.ts +6 -6
  4. package/dist/builtin/intercom/package.json +1 -1
  5. package/dist/builtin/mcp/agent-dir.ts +11 -2
  6. package/dist/builtin/mcp/cli.js +12 -6
  7. package/dist/builtin/mcp/config.ts +31 -22
  8. package/dist/builtin/mcp/package.json +1 -1
  9. package/dist/builtin/subagents/package.json +1 -1
  10. package/dist/builtin/subagents/src/agents/agents.ts +63 -23
  11. package/dist/builtin/subagents/src/agents/skills.ts +21 -21
  12. package/dist/builtin/subagents/src/extension/index.ts +9 -8
  13. package/dist/builtin/subagents/src/runs/shared/run-history.ts +13 -10
  14. package/dist/builtin/subagents/src/runs/shared/subagent-prompt-runtime.ts +3 -3
  15. package/dist/builtin/subagents/src/shared/artifacts.ts +18 -17
  16. package/dist/builtin/subagents/src/shared/types.ts +4 -4
  17. package/dist/builtin/web-access/config-paths.ts +11 -0
  18. package/dist/builtin/web-access/exa.ts +3 -2
  19. package/dist/builtin/web-access/gemini-api.ts +2 -1
  20. package/dist/builtin/web-access/gemini-search.ts +2 -1
  21. package/dist/builtin/web-access/gemini-web-config.ts +2 -1
  22. package/dist/builtin/web-access/github-extract.ts +2 -1
  23. package/dist/builtin/web-access/index.ts +11 -8
  24. package/dist/builtin/web-access/package.json +1 -1
  25. package/dist/builtin/web-access/perplexity.ts +2 -1
  26. package/dist/builtin/web-access/video-extract.ts +2 -1
  27. package/dist/builtin/web-access/youtube-extract.ts +2 -1
  28. package/dist/builtin/workflows/builtin/deep-research-codebase.ts +4 -0
  29. package/dist/builtin/workflows/builtin/open-claude-design.ts +39 -22
  30. package/dist/builtin/workflows/builtin/ralph.ts +7 -0
  31. package/dist/builtin/workflows/package.json +1 -1
  32. package/dist/builtin/workflows/skills/workflow/SKILL.md +28 -20
  33. package/dist/builtin/workflows/skills/workflow/references/design-checklist.md +8 -4
  34. package/dist/builtin/workflows/skills/workflow/references/running-workflows.md +52 -23
  35. package/dist/builtin/workflows/skills/workflow/references/sdk-authoring.md +41 -12
  36. package/dist/builtin/workflows/src/extension/config-loader.ts +13 -14
  37. package/dist/builtin/workflows/src/extension/discovery.ts +4 -6
  38. package/dist/builtin/workflows/src/extension/index.ts +675 -524
  39. package/dist/builtin/workflows/src/extension/runtime.ts +40 -16
  40. package/dist/builtin/workflows/src/extension/wiring.ts +3 -0
  41. package/dist/builtin/workflows/src/extension/workflow-schema.ts +43 -33
  42. package/dist/builtin/workflows/src/runs/foreground/executor.ts +34 -10
  43. package/dist/builtin/workflows/src/shared/types.ts +1 -5
  44. package/dist/builtin/workflows/src/tui/graph-view.ts +245 -75
  45. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +23 -0
  46. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +259 -149
  47. package/dist/builtin/workflows/src/tui/status-helpers.ts +3 -3
  48. package/dist/builtin/workflows/src/tui/store-widget-installer.ts +99 -10
  49. package/dist/builtin/workflows/src/tui/switcher.ts +4 -5
  50. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +29 -0
  51. package/dist/cli/args.d.ts.map +1 -1
  52. package/dist/cli/args.js +11 -8
  53. package/dist/cli/args.js.map +1 -1
  54. package/dist/config.d.ts +21 -0
  55. package/dist/config.d.ts.map +1 -1
  56. package/dist/config.js +59 -4
  57. package/dist/config.js.map +1 -1
  58. package/dist/core/agent-session.d.ts +1 -1
  59. package/dist/core/agent-session.d.ts.map +1 -1
  60. package/dist/core/agent-session.js +2 -2
  61. package/dist/core/agent-session.js.map +1 -1
  62. package/dist/core/auth-storage.d.ts +3 -1
  63. package/dist/core/auth-storage.d.ts.map +1 -1
  64. package/dist/core/auth-storage.js +31 -8
  65. package/dist/core/auth-storage.js.map +1 -1
  66. package/dist/core/extensions/runner.d.ts.map +1 -1
  67. package/dist/core/extensions/runner.js +9 -0
  68. package/dist/core/extensions/runner.js.map +1 -1
  69. package/dist/core/extensions/types.d.ts +11 -0
  70. package/dist/core/extensions/types.d.ts.map +1 -1
  71. package/dist/core/extensions/types.js.map +1 -1
  72. package/dist/core/model-registry.d.ts +3 -2
  73. package/dist/core/model-registry.d.ts.map +1 -1
  74. package/dist/core/model-registry.js +25 -8
  75. package/dist/core/model-registry.js.map +1 -1
  76. package/dist/core/package-manager.d.ts +3 -0
  77. package/dist/core/package-manager.d.ts.map +1 -1
  78. package/dist/core/package-manager.js +97 -58
  79. package/dist/core/package-manager.js.map +1 -1
  80. package/dist/core/resource-loader.d.ts +1 -0
  81. package/dist/core/resource-loader.d.ts.map +1 -1
  82. package/dist/core/resource-loader.js +37 -36
  83. package/dist/core/resource-loader.js.map +1 -1
  84. package/dist/core/sdk.d.ts +5 -4
  85. package/dist/core/sdk.d.ts.map +1 -1
  86. package/dist/core/sdk.js +2 -2
  87. package/dist/core/sdk.js.map +1 -1
  88. package/dist/core/settings-manager.d.ts +7 -1
  89. package/dist/core/settings-manager.d.ts.map +1 -1
  90. package/dist/core/settings-manager.js +29 -8
  91. package/dist/core/settings-manager.js.map +1 -1
  92. package/dist/core/system-prompt.d.ts +1 -1
  93. package/dist/core/system-prompt.d.ts.map +1 -1
  94. package/dist/core/system-prompt.js.map +1 -1
  95. package/dist/core/telemetry.d.ts.map +1 -1
  96. package/dist/core/telemetry.js +2 -2
  97. package/dist/core/telemetry.js.map +1 -1
  98. package/dist/core/timings.d.ts.map +1 -1
  99. package/dist/core/timings.js +2 -2
  100. package/dist/core/timings.js.map +1 -1
  101. package/dist/core/tools/index.d.ts +1 -0
  102. package/dist/core/tools/index.d.ts.map +1 -1
  103. package/dist/core/tools/index.js +8 -0
  104. package/dist/core/tools/index.js.map +1 -1
  105. package/dist/core/tools/todos.d.ts.map +1 -1
  106. package/dist/core/tools/todos.js +3 -3
  107. package/dist/core/tools/todos.js.map +1 -1
  108. package/dist/index.d.ts +2 -2
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +2 -2
  111. package/dist/index.js.map +1 -1
  112. package/dist/main.d.ts.map +1 -1
  113. package/dist/main.js +6 -6
  114. package/dist/main.js.map +1 -1
  115. package/dist/modes/interactive/components/atomic-banner.d.ts +4 -0
  116. package/dist/modes/interactive/components/atomic-banner.d.ts.map +1 -0
  117. package/dist/modes/interactive/components/atomic-banner.js +34 -0
  118. package/dist/modes/interactive/components/atomic-banner.js.map +1 -0
  119. package/dist/modes/interactive/components/chat-message-renderer.d.ts +99 -0
  120. package/dist/modes/interactive/components/chat-message-renderer.d.ts.map +1 -0
  121. package/dist/modes/interactive/components/chat-message-renderer.js +450 -0
  122. package/dist/modes/interactive/components/chat-message-renderer.js.map +1 -0
  123. package/dist/modes/interactive/components/chat-transcript.d.ts +69 -0
  124. package/dist/modes/interactive/components/chat-transcript.d.ts.map +1 -0
  125. package/dist/modes/interactive/components/chat-transcript.js +183 -0
  126. package/dist/modes/interactive/components/chat-transcript.js.map +1 -0
  127. package/dist/modes/interactive/components/footer.d.ts +16 -4
  128. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  129. package/dist/modes/interactive/components/footer.js +110 -137
  130. package/dist/modes/interactive/components/footer.js.map +1 -1
  131. package/dist/modes/interactive/components/index.d.ts +2 -0
  132. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  133. package/dist/modes/interactive/components/index.js +2 -0
  134. package/dist/modes/interactive/components/index.js.map +1 -1
  135. package/dist/modes/interactive/interactive-mode.d.ts +9 -0
  136. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  137. package/dist/modes/interactive/interactive-mode.js +192 -137
  138. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  139. package/dist/modes/interactive/theme/catppuccin-mocha.json +5 -5
  140. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  141. package/dist/modes/rpc/rpc-mode.js +11 -0
  142. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  143. package/dist/utils/tools-manager.d.ts.map +1 -1
  144. package/dist/utils/tools-manager.js +2 -2
  145. package/dist/utils/tools-manager.js.map +1 -1
  146. package/dist/utils/version-check.d.ts.map +1 -1
  147. package/dist/utils/version-check.js +2 -2
  148. package/dist/utils/version-check.js.map +1 -1
  149. package/package.json +1 -1
@@ -37,16 +37,18 @@
37
37
  */
38
38
 
39
39
  import {
40
- AssistantMessageComponent,
40
+ ChatTranscriptComponent,
41
41
  CustomEditor,
42
- parseSkillBlock,
42
+ ScrollableComponentViewport,
43
43
  SessionManager,
44
- SkillInvocationMessageComponent,
45
- ToolExecutionComponent,
46
- UserMessageComponent,
44
+ LiveChatEntriesController,
45
+ chatEntriesFromAgentMessages,
46
+ renderChatMessageEntry,
47
47
  type AgentSession,
48
48
  type AgentSessionEvent,
49
- type SessionMessageEntry,
49
+ type ChatMessageEntry,
50
+ type ChatMessageRenderOptions,
51
+ type ChatTranscriptRole,
50
52
  } from "@bastani/atomic";
51
53
  import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
52
54
  import type { Component, EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui";
@@ -84,6 +86,8 @@ export interface StageChatViewOpts {
84
86
  piKeybindings?: unknown;
85
87
  /** Currently installed host editor factory, inherited from extension `ctx.ui.setEditorComponent()`. */
86
88
  piEditorFactory?: (tui: TUI, theme: EditorTheme, keybindings: unknown) => EditorComponent;
89
+ /** Parent chat rendering settings and extension renderers inherited from the host UI. */
90
+ getChatRenderSettings?: () => Partial<Omit<ChatMessageRenderOptions, "ui" | "cwd" | "markdownTheme">> | undefined;
87
91
  /**
88
92
  * Optional accessor returning the current terminal row count. The chat
89
93
  * surface expands its body band to roughly `viewportRows` minus the fixed
@@ -100,7 +104,7 @@ export interface StageChatViewOpts {
100
104
  * canonical user-visible string without knowing about the Pi-box payload.
101
105
  */
102
106
  interface BaseEntry {
103
- readonly role: "user" | "assistant" | "thinking" | "tool" | "notice" | "system";
107
+ readonly role: ChatTranscriptRole;
104
108
  readonly text: string;
105
109
  }
106
110
  interface UserEntry extends BaseEntry {
@@ -137,7 +141,8 @@ type TranscriptEntry =
137
141
  | ThinkingEntry
138
142
  | SystemEntry
139
143
  | ToolEntry
140
- | NoticeEntry;
144
+ | NoticeEntry
145
+ | ChatMessageEntry;
141
146
  type AgentSnapshotMessage = AgentSession["messages"][number];
142
147
 
143
148
  // ---------------------------------------------------------------------------
@@ -155,10 +160,6 @@ const VIEW_LINE_COUNT = 32;
155
160
  const HEADER_ROWS = 1;
156
161
  /** Single dim rule between header and body. */
157
162
  const SEP_ROWS = 1;
158
- /** Loader: top rule + body + bottom rule when streaming. */
159
- const LOADER_ROWS = 3;
160
- /** Editor: top rule + ` ❯ … ` + bottom rule, always present. */
161
- const EDITOR_ROWS = 3;
162
163
  /** Footer: two dim lines. */
163
164
  const FOOTER_ROWS = 2;
164
165
  /** Hint strip: dashed rule + key bindings line. */
@@ -174,45 +175,6 @@ const FG_RESET = "\x1b[39m";
174
175
  const WEIGHT_RESET = "\x1b[22m";
175
176
  const ITALIC_RESET = "\x1b[23m";
176
177
 
177
- // ---------------------------------------------------------------------------
178
- // Pi chat transcript adapter
179
- // ---------------------------------------------------------------------------
180
-
181
- /**
182
- * Composes stage transcript rows with the same spacing rules as pi's
183
- * InteractiveMode chat container. Workflow chrome (header, loader, footer,
184
- * hints) remains owned by StageChatView; the base chat body is just the
185
- * canonical coding-agent message components inside a pi-tui Container.
186
- */
187
- class PiChatTranscriptComponent implements Component {
188
- constructor(
189
- private readonly entries: readonly TranscriptEntry[],
190
- private readonly renderEntry: (entry: TranscriptEntry) => Component,
191
- ) {}
192
-
193
- render(width: number): string[] {
194
- const container = new Container();
195
- for (const entry of this.entries) {
196
- addTranscriptEntry(container, this.renderEntry(entry), entry.role);
197
- }
198
- return container.render(width);
199
- }
200
-
201
- invalidate(): void {}
202
- }
203
-
204
- function addTranscriptEntry(container: Container, component: Component, role: TranscriptEntry["role"]): void {
205
- // Mirror InteractiveMode.addMessageToChat:
206
- // - user/custom/system-like rows get a spacer only when something already
207
- // exists above them;
208
- // - assistant rows own their leading whitespace internally;
209
- // - tool rows attach directly below the assistant turn that requested them.
210
- if ((role === "user" || role === "notice" || role === "system") && container.children.length > 0) {
211
- container.addChild(new Spacer(1));
212
- }
213
- container.addChild(component);
214
- }
215
-
216
178
  // ---------------------------------------------------------------------------
217
179
  // StageChatView
218
180
  // ---------------------------------------------------------------------------
@@ -229,6 +191,7 @@ export class StageChatView implements Component {
229
191
  private requestRender: (() => void) | undefined;
230
192
  private getViewportRows?: () => number | undefined;
231
193
  private editor: EditorComponent | undefined;
194
+ private getChatRenderSettings?: () => Partial<Omit<ChatMessageRenderOptions, "ui" | "cwd" | "markdownTheme">> | undefined;
232
195
 
233
196
  private inputBuffer = "";
234
197
  private transcript: TranscriptEntry[] = [];
@@ -250,6 +213,9 @@ export class StageChatView implements Component {
250
213
  private optimisticUserSignatures = new Set<string>();
251
214
  /** Chat-mode repaint driver for Pi-style loaders/spinners. */
252
215
  private animationTimer: ReturnType<typeof setInterval> | undefined;
216
+ /** Scrollable fixed-height body viewport for attached chat history. */
217
+ private bodyViewport = new ScrollableComponentViewport();
218
+ private liveChat: LiveChatEntriesController;
253
219
 
254
220
  private _unsubscribeStore: (() => void) | null = null;
255
221
  private _unsubscribeHandle: (() => void) | null = null;
@@ -265,6 +231,8 @@ export class StageChatView implements Component {
265
231
  this.onClose = opts.onClose;
266
232
  this.requestRender = opts.requestRender;
267
233
  this.getViewportRows = opts.getViewportRows;
234
+ this.getChatRenderSettings = opts.getChatRenderSettings;
235
+ this.liveChat = new LiveChatEntriesController(this.transcript);
268
236
  this.editor = this._createEditor(opts.piTui, opts.piKeybindings, opts.piEditorFactory);
269
237
 
270
238
  // Seed transcript from the live SDK session at attach time, plus any
@@ -345,10 +313,7 @@ export class StageChatView implements Component {
345
313
 
346
314
  private _snapshotMessagesFromHandle(): void {
347
315
  if (!this.handle) return;
348
- for (const message of this.handle.messages) {
349
- const entry = transcriptEntryFromSnapshotMessage(message);
350
- if (entry) this.transcript.push(entry);
351
- }
316
+ this.liveChat.appendMessages(this.handle.messages);
352
317
  }
353
318
 
354
319
  private _snapshotMessagesFromSessionFile(stage: StageSnapshot | undefined): void {
@@ -356,30 +321,36 @@ export class StageChatView implements Component {
356
321
  const sessionFile = this.handle?.sessionFile ?? stage?.sessionFile;
357
322
  if (sessionFile === undefined) return;
358
323
 
359
- let entries: ReturnType<SessionManager["getEntries"]>;
324
+ let messages: readonly AgentSnapshotMessage[];
360
325
  try {
361
- entries = SessionManager.open(sessionFile).getEntries();
326
+ messages = SessionManager.open(sessionFile).buildSessionContext().messages as readonly AgentSnapshotMessage[];
362
327
  } catch {
363
328
  return;
364
329
  }
365
330
 
366
- for (const entry of entries) {
367
- if (!isSessionMessageEntry(entry)) continue;
368
- const transcriptEntry = transcriptEntryFromSnapshotMessage(entry.message as AgentSnapshotMessage);
369
- if (transcriptEntry) this.transcript.push(transcriptEntry);
370
- }
331
+ this.liveChat.appendMessages(messages);
371
332
  }
372
333
 
373
334
  private _appendEvent(event: AgentSessionEvent): boolean {
374
- // This mirrors pi-coding-agent InteractiveMode's event controller shape:
375
- // session events mutate a long-lived chat model, then the host TUI is
376
- // asked to render. Assistant rows are driven from full message snapshots
377
- // when present; delta-only events remain supported for SDK/test shims.
335
+ // Shared live transcript ingestion covers assistant/user/custom messages
336
+ // and tool start/update/end rows. StageChatView keeps workflow-only status
337
+ // events (pause, compaction captions, animation state) locally.
378
338
  const type = String((event as { type?: unknown }).type ?? "");
339
+ if (type === "message_start") {
340
+ const message = (event as { message?: unknown }).message;
341
+ if (isUserMessageLike(message)) {
342
+ const signature = userMessageSignature(extractMessageText(message.content));
343
+ if (this.optimisticUserSignatures.delete(signature)) return false;
344
+ }
345
+ }
346
+ if (isSharedLiveChatEvent(type)) {
347
+ return this.liveChat.applyEvent(event);
348
+ }
379
349
  switch (type) {
380
350
  case "agent_start":
381
351
  this.sdkBusy = true;
382
352
  this.toolEntryIndexes.clear();
353
+ this.liveChat.clearPendingTools();
383
354
  this.statusMessage = "";
384
355
  return true;
385
356
 
@@ -387,6 +358,7 @@ export class StageChatView implements Component {
387
358
  this.sdkBusy = false;
388
359
  this.streamingAssistantIndex = undefined;
389
360
  this.streamingThinkingIndex = undefined;
361
+ this.liveChat.clearPendingTools();
390
362
  this.statusMessage = "";
391
363
  return true;
392
364
 
@@ -538,7 +510,7 @@ export class StageChatView implements Component {
538
510
  changed = this._updateAssistantFromMessage(message) || changed;
539
511
  for (const [toolCallId, index] of this.toolEntryIndexes.entries()) {
540
512
  const entry = this.transcript[index];
541
- if (entry?.role === "tool" && entry.state === "pending") {
513
+ if (isLocalToolEntry(entry) && entry.state === "pending") {
542
514
  this.transcript[index] = { ...entry, text: entry.text };
543
515
  }
544
516
  this.toolEntryIndexes.set(toolCallId, index);
@@ -586,7 +558,7 @@ export class StageChatView implements Component {
586
558
  return true;
587
559
  }
588
560
  const index = role === "assistant" ? this.streamingAssistantIndex : this.streamingThinkingIndex;
589
- if (index !== undefined && this.transcript[index]?.role === role) {
561
+ if (index !== undefined && isLocalTextRoleEntry(this.transcript[index], role)) {
590
562
  if (this.transcript[index]?.text === text) return false;
591
563
  this.transcript[index] = { role, text } as TranscriptEntry;
592
564
  return true;
@@ -624,7 +596,7 @@ export class StageChatView implements Component {
624
596
  text: string,
625
597
  ): void {
626
598
  const last = this.transcript[this.transcript.length - 1];
627
- if (last && last.role === role) {
599
+ if (isLocalTextRoleEntry(last, role)) {
628
600
  this.transcript[this.transcript.length - 1] = { role, text } as TranscriptEntry;
629
601
  } else {
630
602
  this.transcript.push({ role, text } as TranscriptEntry);
@@ -636,7 +608,7 @@ export class StageChatView implements Component {
636
608
  delta: string,
637
609
  ): void {
638
610
  const index = role === "assistant" ? this.streamingAssistantIndex : this.streamingThinkingIndex;
639
- if (index !== undefined && this.transcript[index]?.role === role) {
611
+ if (index !== undefined && isLocalTextRoleEntry(this.transcript[index], role)) {
640
612
  const current = this.transcript[index];
641
613
  this.transcript[index] = { role, text: current.text + delta } as TranscriptEntry;
642
614
  return;
@@ -651,7 +623,7 @@ export class StageChatView implements Component {
651
623
  let changed = false;
652
624
  for (const [toolCallId, index] of this.toolEntryIndexes.entries()) {
653
625
  const entry = this.transcript[index];
654
- if (entry?.role !== "tool" || entry.state !== "pending") continue;
626
+ if (!isLocalToolEntry(entry) || entry.state !== "pending") continue;
655
627
  changed = this._upsertToolEntry({
656
628
  toolCallId,
657
629
  name: entry.name,
@@ -673,7 +645,7 @@ export class StageChatView implements Component {
673
645
  const mappedIndex = update.toolCallId ? this.toolEntryIndexes.get(update.toolCallId) : undefined;
674
646
  const index = mappedIndex ?? findToolEntryIndex(this.transcript, update.toolCallId, update.name);
675
647
  const existing = index !== undefined && index >= 0 ? this.transcript[index] : undefined;
676
- const previous = existing?.role === "tool" ? existing : undefined;
648
+ const previous = isLocalToolEntry(existing) ? existing : undefined;
677
649
  const output = update.output || previous?.output;
678
650
  const name = previous?.name ?? update.name;
679
651
  const args = update.args ?? previous?.args;
@@ -731,7 +703,8 @@ export class StageChatView implements Component {
731
703
  }
732
704
 
733
705
  private _hasPendingToolEntries(): boolean {
734
- return this.transcript.some((entry) => entry.role === "tool" && entry.state === "pending");
706
+ return this.liveChat.pendingToolIds().length > 0 ||
707
+ this.transcript.some((entry) => isLocalToolEntry(entry) && entry.state === "pending");
735
708
  }
736
709
 
737
710
  private _syncAnimationTick(): void {
@@ -783,21 +756,23 @@ export class StageChatView implements Component {
783
756
  blocked,
784
757
  omitTopRule: loaderLines.length > 0,
785
758
  });
786
- const footerLines = this._renderFooter(w, stage, { paused, streaming, settled });
787
759
  const hintsLines = this._renderHints(w, { paused, streaming, settled });
788
760
 
789
761
  const fixed =
790
- headerLines.length +
791
- sepLines.length +
762
+ HEADER_ROWS +
763
+ SEP_ROWS +
792
764
  loaderLines.length +
793
765
  editorLines.length +
794
- footerLines.length +
795
- hintsLines.length;
766
+ FOOTER_ROWS +
767
+ HINTS_ROWS;
796
768
  const totalRows = this._viewLineCount();
797
769
  const bodyBudget = Math.max(1, totalRows - fixed);
770
+ this.bodyViewport.setVisibleRows(bodyBudget);
771
+ if (blocked) this.bodyViewport.scrollToBottom();
798
772
  const bodyLines = blocked
799
773
  ? this._renderBlockedBody(w, bodyBudget, stage)
800
774
  : this._renderBody(w, bodyBudget, stage, { paused, streaming, settled });
775
+ const footerLines = this._renderFooter(w, stage, { paused, streaming, settled });
801
776
 
802
777
  const lines = [
803
778
  ...headerLines,
@@ -953,7 +928,9 @@ export class StageChatView implements Component {
953
928
  // transcript component so the attached stage chat uses the same message
954
929
  // spacing and coding-agent message widgets as the main interactive chat.
955
930
  if (this.transcript.length > 0) {
956
- components.push(new PiChatTranscriptComponent(this.transcript, (entry) => this._renderEntry(entry)));
931
+ components.push(
932
+ new ChatTranscriptComponent(this.transcript, (entry) => this._renderEntry(entry)),
933
+ );
957
934
  }
958
935
 
959
936
  // Stream a static status message (e.g. "pausing…") as a dim trailing row.
@@ -962,24 +939,8 @@ export class StageChatView implements Component {
962
939
  components.push(new Text(paint(this.statusMessage, this.theme.dim), 2, 0));
963
940
  }
964
941
 
965
- // Flatten from the tail + sticky-bottom — show the most recent content.
966
- // This keeps the 80ms Pi-style spinner tick cheap even after long chats:
967
- // off-screen history is not rebuilt just to be sliced away.
968
- return this._renderComponentTail(components, width, budget);
969
- }
970
-
971
- private _renderComponentTail(components: Component[], width: number, budget: number): string[] {
972
- const chunks: string[][] = [];
973
- let lineCount = 0;
974
- for (let i = components.length - 1; i >= 0; i--) {
975
- const lines = components[i]!.render(width);
976
- chunks.push(lines);
977
- lineCount += lines.length;
978
- if (lineCount >= budget) break;
979
- }
980
- const flat: string[] = [];
981
- for (let i = chunks.length - 1; i >= 0; i--) flat.push(...chunks[i]!);
982
- return this._fitToBudget(flat, budget, width);
942
+ this.bodyViewport.setComponents(components);
943
+ return this.bodyViewport.render(width);
983
944
  }
984
945
 
985
946
  private _fitToBudget(lines: string[], budget: number, width: number): string[] {
@@ -1038,57 +999,68 @@ export class StageChatView implements Component {
1038
999
  // -------------------------------------------------------------------------
1039
1000
 
1040
1001
  private _renderEntry(entry: TranscriptEntry): Component {
1002
+ if (isChatMessageEntry(entry)) {
1003
+ return renderChatMessageEntry(entry, this._chatMessageRenderOptions());
1004
+ }
1041
1005
  switch (entry.role) {
1042
1006
  case "user":
1043
- return this._userMessage(entry.text);
1007
+ return renderChatMessageEntry(
1008
+ { role: "user", kind: "user", text: entry.text },
1009
+ this._chatMessageRenderOptions(),
1010
+ );
1044
1011
  case "assistant":
1045
- return new AssistantMessageComponent(assistantMessageForText(entry.text));
1012
+ return renderChatMessageEntry(
1013
+ { role: "assistant", kind: "assistant", message: assistantMessageForText(entry.text) },
1014
+ this._chatMessageRenderOptions(),
1015
+ );
1046
1016
  case "thinking":
1047
- return new AssistantMessageComponent(assistantMessageForThinking(entry.text));
1017
+ return renderChatMessageEntry(
1018
+ { role: "assistant", kind: "assistant", message: assistantMessageForThinking(entry.text) },
1019
+ this._chatMessageRenderOptions(),
1020
+ );
1048
1021
  case "tool":
1049
- return this._toolExecution(entry);
1022
+ return renderChatMessageEntry(this._toolEntryToChatMessage(entry), this._chatMessageRenderOptions());
1050
1023
  case "notice":
1051
1024
  return this._noticeRow(entry);
1052
1025
  case "system":
1053
- return new Text(paint(entry.text, this.theme.dim), 2, 0);
1026
+ return renderChatMessageEntry(
1027
+ { role: "system", kind: "system", text: entry.text },
1028
+ this._chatMessageRenderOptions(),
1029
+ );
1054
1030
  }
1055
1031
  }
1056
1032
 
1057
- private _userMessage(text: string): Component {
1058
- const skillBlock = parseSkillBlock(text);
1059
- if (!skillBlock) return new UserMessageComponent(text);
1060
-
1061
- const container = new Container();
1062
- container.addChild(new SkillInvocationMessageComponent(skillBlock));
1063
- if (skillBlock.userMessage) {
1064
- container.addChild(new UserMessageComponent(skillBlock.userMessage));
1065
- }
1066
- return container;
1033
+ private _toolEntryToChatMessage(entry: ToolEntry): ChatMessageEntry {
1034
+ const toolCallId = entry.toolCallId ?? `workflow-${entry.name}`;
1035
+ return {
1036
+ role: "tool",
1037
+ kind: "tool",
1038
+ toolName: entry.name,
1039
+ toolCallId,
1040
+ args: toolArgsForRender(entry),
1041
+ isPartial: entry.state === "pending",
1042
+ result:
1043
+ entry.state !== "pending" || entry.output
1044
+ ? {
1045
+ role: "toolResult",
1046
+ toolCallId,
1047
+ toolName: entry.name,
1048
+ content: entry.output ? [{ type: "text", text: entry.output }] : [],
1049
+ isError: entry.state === "error",
1050
+ timestamp: Date.now(),
1051
+ }
1052
+ : undefined,
1053
+ };
1067
1054
  }
1068
1055
 
1069
- private _toolExecution(entry: ToolEntry): Component {
1070
- const component = new ToolExecutionComponent(
1071
- entry.name,
1072
- entry.toolCallId ?? `workflow-${entry.name}`,
1073
- toolArgsForRender(entry),
1074
- { showImages: true },
1075
- undefined,
1076
- this._toolTui(),
1077
- process.cwd(),
1078
- );
1079
- if (entry.state !== "pending" || entry.output) {
1080
- component.updateResult(
1081
- {
1082
- content: entry.output
1083
- ? [{ type: "text", text: entry.output }]
1084
- : [],
1085
- isError: entry.state === "error",
1086
- details: {},
1087
- },
1088
- entry.state === "pending",
1089
- );
1090
- }
1091
- return component;
1056
+ private _chatMessageRenderOptions(): ChatMessageRenderOptions {
1057
+ const inherited = this.getChatRenderSettings?.();
1058
+ return {
1059
+ ...inherited,
1060
+ ui: this._toolTui(),
1061
+ cwd: process.cwd(),
1062
+ showImages: inherited?.showImages ?? true,
1063
+ };
1092
1064
  }
1093
1065
 
1094
1066
  private _toolTui(): TUI {
@@ -1249,9 +1221,15 @@ export class StageChatView implements Component {
1249
1221
  const top = layoutRow(width, " ", " " + lTop, rTop + " ", t);
1250
1222
 
1251
1223
  // Bottom line — left: messages / duration; right: caption
1224
+ const history = this.bodyViewport.getMaxScroll() > 0
1225
+ ? this.bodyViewport.getScrollFromBottom() > 0
1226
+ ? ` · history ↑${this.bodyViewport.getScrollFromBottom()}`
1227
+ : " · history bottom"
1228
+ : "";
1252
1229
  const lBot =
1253
1230
  paint(`◇ ${messages} messages`, t.dim) +
1254
- (dur ? " " + paint(`· ${dur}`, t.dim) : "");
1231
+ (dur ? " " + paint(`· ${dur}`, t.dim) : "") +
1232
+ paint(history, t.dim);
1255
1233
  const rBot = flags.streaming
1256
1234
  ? paint("streaming · live", t.accent)
1257
1235
  : flags.paused
@@ -1303,8 +1281,10 @@ export class StageChatView implements Component {
1303
1281
  streaming: boolean;
1304
1282
  settled: boolean;
1305
1283
  }): Array<{ key: string; label: string; emphasis?: boolean }> {
1284
+ const historyHint = { key: "PgUp/PgDn", label: "history" };
1306
1285
  if (flags.settled) {
1307
1286
  return [
1287
+ historyHint,
1308
1288
  { key: "Ctrl+D", label: "back to graph", emphasis: true },
1309
1289
  { key: "Esc", label: "close" },
1310
1290
  ];
@@ -1313,6 +1293,7 @@ export class StageChatView implements Component {
1313
1293
  return [
1314
1294
  { key: "↵", label: "resume with message", emphasis: true },
1315
1295
  { key: "Ctrl+P", label: "resume empty" },
1296
+ historyHint,
1316
1297
  { key: "Ctrl+D", label: "back" },
1317
1298
  { key: "Esc", label: "close" },
1318
1299
  ];
@@ -1322,6 +1303,7 @@ export class StageChatView implements Component {
1322
1303
  { key: "↵", label: "steer", emphasis: true },
1323
1304
  { key: "Ctrl+F", label: "follow-up", emphasis: true },
1324
1305
  { key: "Ctrl+P", label: "pause" },
1306
+ historyHint,
1325
1307
  { key: "Ctrl+D", label: "back" },
1326
1308
  { key: "Esc", label: "interrupt" },
1327
1309
  ];
@@ -1330,6 +1312,7 @@ export class StageChatView implements Component {
1330
1312
  { key: "↵", label: "send", emphasis: true },
1331
1313
  { key: "Ctrl+F", label: "follow-up" },
1332
1314
  { key: "Ctrl+P", label: "pause" },
1315
+ historyHint,
1333
1316
  { key: "Ctrl+D", label: "back" },
1334
1317
  { key: "Esc", label: "close" },
1335
1318
  ];
@@ -1351,11 +1334,18 @@ export class StageChatView implements Component {
1351
1334
  return " ".repeat(width);
1352
1335
  }
1353
1336
 
1337
+ wantsMouseScrollTracking(): boolean {
1338
+ return true;
1339
+ }
1340
+
1354
1341
  // -------------------------------------------------------------------------
1355
1342
  // Input
1356
1343
  // -------------------------------------------------------------------------
1357
1344
 
1358
1345
  handleInput(data: string): boolean {
1346
+ if (this.bodyViewport.handleInput(data)) {
1347
+ return true;
1348
+ }
1359
1349
  if (data === "\x04") {
1360
1350
  this.onDetach();
1361
1351
  return true;
@@ -1438,7 +1428,8 @@ export class StageChatView implements Component {
1438
1428
  this.requestRender?.();
1439
1429
  return;
1440
1430
  }
1441
- this.transcript.push({ role: "user", text });
1431
+ this.liveChat.appendUserText(text);
1432
+ this.bodyViewport.scrollToBottom();
1442
1433
  this.optimisticUserSignatures.add(userMessageSignature(text));
1443
1434
  this.requestRender?.();
1444
1435
  try {
@@ -1491,8 +1482,22 @@ export class StageChatView implements Component {
1491
1482
  get _inputBuffer(): string {
1492
1483
  return this.inputBuffer;
1493
1484
  }
1494
- get _transcript(): readonly TranscriptEntry[] {
1495
- return this.transcript;
1485
+ get _transcript(): ReadonlyArray<
1486
+ TranscriptEntry & {
1487
+ readonly text: string;
1488
+ readonly toolCallId: string;
1489
+ readonly state: string;
1490
+ readonly output: string;
1491
+ }
1492
+ > {
1493
+ return this.transcript.flatMap((entry) => transcriptDebugEntries(entry)) as ReadonlyArray<
1494
+ TranscriptEntry & {
1495
+ readonly text: string;
1496
+ readonly toolCallId: string;
1497
+ readonly state: string;
1498
+ readonly output: string;
1499
+ }
1500
+ >;
1496
1501
  }
1497
1502
  get _statusMessage(): string {
1498
1503
  return this.statusMessage;
@@ -1503,15 +1508,112 @@ export class StageChatView implements Component {
1503
1508
  get _hasAnimationTick(): boolean {
1504
1509
  return this.animationTimer !== undefined;
1505
1510
  }
1511
+ get _bodyScrollFromBottom(): number {
1512
+ return this.bodyViewport.getScrollFromBottom();
1513
+ }
1514
+ get _lastBodyMaxScroll(): number {
1515
+ return this.bodyViewport.getMaxScroll();
1516
+ }
1506
1517
  }
1507
1518
 
1508
1519
  // ---------------------------------------------------------------------------
1509
1520
  // Module-private helpers
1510
1521
  // ---------------------------------------------------------------------------
1511
1522
 
1512
- type AssistantComponentMessage = NonNullable<
1513
- ConstructorParameters<typeof AssistantMessageComponent>[0]
1514
- >;
1523
+ type AssistantComponentMessage = Extract<ChatMessageEntry, { kind: "assistant" }>["message"];
1524
+
1525
+ function transcriptDebugEntries(entry: TranscriptEntry): Array<TranscriptEntry & {
1526
+ readonly text: string;
1527
+ readonly toolCallId: string;
1528
+ readonly state: string;
1529
+ readonly output: string;
1530
+ }> {
1531
+ if (isChatMessageEntry(entry) && entry.kind === "assistant") {
1532
+ const entries: Array<TranscriptEntry & { text: string; toolCallId: string; state: string; output: string }> = [];
1533
+ const thinking = extractThinkingText(entry.message.content);
1534
+ const text = extractMessageText(entry.message.content);
1535
+ if (thinking) entries.push({ role: "thinking", text: thinking, toolCallId: "", state: "", output: "" } as TranscriptEntry & { text: string; toolCallId: string; state: string; output: string });
1536
+ if (text || entries.length === 0) entries.push({ ...entry, text, toolCallId: "", state: "", output: "" });
1537
+ return entries;
1538
+ }
1539
+ return [{
1540
+ ...entry,
1541
+ text: transcriptDebugText(entry),
1542
+ toolCallId: transcriptDebugToolCallId(entry),
1543
+ state: transcriptDebugToolState(entry),
1544
+ output: transcriptDebugToolOutput(entry),
1545
+ } as TranscriptEntry & { text: string; toolCallId: string; state: string; output: string }];
1546
+ }
1547
+
1548
+ function transcriptDebugText(entry: TranscriptEntry): string {
1549
+ if ("text" in entry && typeof entry.text === "string") return entry.text;
1550
+ if (isChatMessageEntry(entry)) {
1551
+ switch (entry.kind) {
1552
+ case "assistant":
1553
+ return extractMessageText(entry.message.content);
1554
+ case "tool":
1555
+ return entry.result
1556
+ ? extractToolResultText(entry.result)
1557
+ : `${entry.toolName} ${typeof entry.args === "string" ? entry.args : JSON.stringify(entry.args ?? {})}`;
1558
+ case "bashExecution":
1559
+ return entry.message.output || entry.message.command;
1560
+ case "user":
1561
+ case "system":
1562
+ return entry.text;
1563
+ case "custom":
1564
+ return extractMessageText(entry.message.content);
1565
+ case "branchSummary":
1566
+ case "compactionSummary":
1567
+ return entry.message.summary;
1568
+ }
1569
+ }
1570
+ return "";
1571
+ }
1572
+
1573
+ function transcriptDebugToolCallId(entry: TranscriptEntry): string {
1574
+ if (isChatMessageEntry(entry) && entry.kind === "tool") return entry.toolCallId;
1575
+ if ("toolCallId" in entry && typeof entry.toolCallId === "string") return entry.toolCallId;
1576
+ return "";
1577
+ }
1578
+
1579
+ function transcriptDebugToolState(entry: TranscriptEntry): string {
1580
+ if (isChatMessageEntry(entry) && entry.kind === "tool") {
1581
+ if (entry.result?.isError) return "error";
1582
+ return entry.isPartial === false ? "success" : "pending";
1583
+ }
1584
+ if ("state" in entry && typeof entry.state === "string") return entry.state;
1585
+ return "";
1586
+ }
1587
+
1588
+ function transcriptDebugToolOutput(entry: TranscriptEntry): string {
1589
+ if (isChatMessageEntry(entry) && entry.kind === "tool") return entry.result ? extractToolResultText(entry.result) : "";
1590
+ if ("output" in entry && typeof entry.output === "string") return entry.output;
1591
+ return "";
1592
+ }
1593
+
1594
+ function isSharedLiveChatEvent(type: string): boolean {
1595
+ return type === "message_start" ||
1596
+ type === "message_update" ||
1597
+ type === "message_end" ||
1598
+ type === "tool_execution_start" ||
1599
+ type === "tool_execution_update" ||
1600
+ type === "tool_execution_end";
1601
+ }
1602
+
1603
+ function isChatMessageEntry(entry: TranscriptEntry): entry is ChatMessageEntry {
1604
+ return "kind" in entry && entry.role !== "notice";
1605
+ }
1606
+
1607
+ function isLocalToolEntry(entry: TranscriptEntry | undefined): entry is ToolEntry {
1608
+ return entry?.role === "tool" && !("kind" in entry);
1609
+ }
1610
+
1611
+ function isLocalTextRoleEntry<T extends "user" | "assistant" | "thinking" | "system">(
1612
+ entry: TranscriptEntry | undefined,
1613
+ role: T,
1614
+ ): entry is Extract<TranscriptEntry, { role: T; text: string }> {
1615
+ return entry?.role === role && !("kind" in entry) && "text" in entry;
1616
+ }
1515
1617
 
1516
1618
  function assistantMessageForText(text: string): AssistantComponentMessage {
1517
1619
  return {
@@ -1541,6 +1643,10 @@ function isMessageLike(message: unknown): message is { role?: unknown; content?:
1541
1643
  return message !== null && typeof message === "object" && "role" in message;
1542
1644
  }
1543
1645
 
1646
+ function isUserMessageLike(message: unknown): message is { role: "user"; content?: unknown } {
1647
+ return isMessageLike(message) && message.role === "user";
1648
+ }
1649
+
1544
1650
  function userMessageSignature(text: string): string {
1545
1651
  return text.trim();
1546
1652
  }
@@ -1677,11 +1783,15 @@ function transcriptEntryFromSnapshotMessage(
1677
1783
  }
1678
1784
  }
1679
1785
 
1680
- function isSessionMessageEntry(entry: unknown): entry is SessionMessageEntry {
1681
- return entry !== null &&
1682
- typeof entry === "object" &&
1683
- (entry as { type?: unknown }).type === "message" &&
1684
- "message" in entry;
1786
+ function extractThinkingText(content: unknown): string {
1787
+ if (!Array.isArray(content)) return "";
1788
+ const parts: string[] = [];
1789
+ for (const item of content) {
1790
+ if (item == null || typeof item !== "object") continue;
1791
+ const thinking = (item as { type?: unknown; thinking?: unknown }).thinking;
1792
+ if ((item as { type?: unknown }).type === "thinking" && typeof thinking === "string") parts.push(thinking);
1793
+ }
1794
+ return parts.join("\n\n");
1685
1795
  }
1686
1796
 
1687
1797
  function extractMessageText(content: unknown): string {
@@ -1716,12 +1826,12 @@ function findToolEntryIndex(
1716
1826
  if (toolCallId !== undefined) {
1717
1827
  for (let i = entries.length - 1; i >= 0; i--) {
1718
1828
  const entry = entries[i];
1719
- if (entry?.role === "tool" && entry.toolCallId === toolCallId) return i;
1829
+ if (isLocalToolEntry(entry) && entry.toolCallId === toolCallId) return i;
1720
1830
  }
1721
1831
  }
1722
1832
  for (let i = entries.length - 1; i >= 0; i--) {
1723
1833
  const entry = entries[i];
1724
- if (entry?.role === "tool" && entry.name === name && entry.state === "pending") return i;
1834
+ if (isLocalToolEntry(entry) && entry.name === name && entry.state === "pending") return i;
1725
1835
  }
1726
1836
  return -1;
1727
1837
  }