@bastani/atomic 0.8.4 → 0.8.5

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 (245) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +24 -23
  3. package/dist/builtin/intercom/README.md +5 -5
  4. package/dist/builtin/intercom/index.ts +1 -1
  5. package/dist/builtin/intercom/package.json +1 -1
  6. package/dist/builtin/intercom/ui/compose.ts +19 -1
  7. package/dist/builtin/intercom/ui/session-list.ts +19 -1
  8. package/dist/builtin/mcp/README.md +3 -3
  9. package/dist/builtin/mcp/commands.ts +1 -1
  10. package/dist/builtin/mcp/host-html-template.ts +1 -1
  11. package/dist/builtin/mcp/mcp-panel.ts +14 -14
  12. package/dist/builtin/mcp/mcp-setup-panel.ts +4 -4
  13. package/dist/builtin/mcp/package.json +1 -1
  14. package/dist/builtin/mcp/tool-result-renderer.ts +1 -1
  15. package/dist/builtin/subagents/README.md +3 -3
  16. package/dist/builtin/subagents/package.json +1 -1
  17. package/dist/builtin/subagents/src/tui/render.ts +1844 -1062
  18. package/dist/builtin/web-access/README.md +1 -1
  19. package/dist/builtin/web-access/curator-page.ts +2 -2
  20. package/dist/builtin/web-access/index.ts +1 -1
  21. package/dist/builtin/web-access/package.json +1 -1
  22. package/dist/builtin/workflows/README.md +34 -7
  23. package/dist/builtin/workflows/builtin/deep-research-codebase.ts +23 -4
  24. package/dist/builtin/workflows/builtin/ralph.ts +1 -1
  25. package/dist/builtin/workflows/package.json +1 -1
  26. package/dist/builtin/workflows/skills/workflow/SKILL.md +75 -16
  27. package/dist/builtin/workflows/skills/workflow/references/running-workflows.md +34 -11
  28. package/dist/builtin/workflows/skills/workflow/references/sdk-authoring.md +111 -20
  29. package/dist/builtin/workflows/src/extension/discovery.ts +32 -4
  30. package/dist/builtin/workflows/src/extension/index.ts +347 -63
  31. package/dist/builtin/workflows/src/extension/render-call.ts +3 -1
  32. package/dist/builtin/workflows/src/extension/render-result.ts +7 -0
  33. package/dist/builtin/workflows/src/extension/runtime.ts +4 -2
  34. package/dist/builtin/workflows/src/extension/wiring.ts +32 -8
  35. package/dist/builtin/workflows/src/extension/workflow-schema.ts +36 -14
  36. package/dist/builtin/workflows/src/runs/background/runner.ts +2 -2
  37. package/dist/builtin/workflows/src/runs/background/status.ts +89 -0
  38. package/dist/builtin/workflows/src/runs/foreground/executor.ts +338 -78
  39. package/dist/builtin/workflows/src/runs/foreground/stage-control-registry.ts +2 -0
  40. package/dist/builtin/workflows/src/runs/foreground/stage-runner.ts +55 -7
  41. package/dist/builtin/workflows/src/runs/shared/workflow-runner.ts +146 -10
  42. package/dist/builtin/workflows/src/shared/store.ts +29 -0
  43. package/dist/builtin/workflows/src/shared/types.ts +25 -4
  44. package/dist/builtin/workflows/src/tui/graph-canvas.ts +69 -2
  45. package/dist/builtin/workflows/src/tui/graph-view.ts +97 -182
  46. package/dist/builtin/workflows/src/tui/header.ts +36 -20
  47. package/dist/builtin/workflows/src/tui/inline-form-card.ts +129 -46
  48. package/dist/builtin/workflows/src/tui/inline-form-editor.ts +111 -36
  49. package/dist/builtin/workflows/src/tui/inputs-picker.ts +311 -91
  50. package/dist/builtin/workflows/src/tui/layout.ts +1 -1
  51. package/dist/builtin/workflows/src/tui/node-card.ts +66 -37
  52. package/dist/builtin/workflows/src/tui/overlay-adapter.ts +20 -6
  53. package/dist/builtin/workflows/src/tui/prompt-card.ts +262 -85
  54. package/dist/builtin/workflows/src/tui/run-detail.ts +50 -31
  55. package/dist/builtin/workflows/src/tui/session-confirm.ts +21 -14
  56. package/dist/builtin/workflows/src/tui/session-picker.ts +35 -26
  57. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +531 -960
  58. package/dist/builtin/workflows/src/tui/status-helpers.ts +6 -0
  59. package/dist/builtin/workflows/src/tui/status-list.ts +8 -4
  60. package/dist/builtin/workflows/src/tui/store-widget-installer.ts +7 -2
  61. package/dist/builtin/workflows/src/tui/switcher.ts +55 -25
  62. package/dist/builtin/workflows/src/tui/workflow-attach-pane.ts +33 -1
  63. package/dist/builtin/workflows/src/tui/workflow-list.ts +10 -6
  64. package/dist/cli/args.d.ts.map +1 -1
  65. package/dist/cli/args.js +1 -1
  66. package/dist/cli/args.js.map +1 -1
  67. package/dist/config.d.ts.map +1 -1
  68. package/dist/config.js +20 -6
  69. package/dist/config.js.map +1 -1
  70. package/dist/core/agent-session-services.d.ts +3 -3
  71. package/dist/core/agent-session-services.d.ts.map +1 -1
  72. package/dist/core/agent-session-services.js.map +1 -1
  73. package/dist/core/agent-session.d.ts +7 -7
  74. package/dist/core/agent-session.d.ts.map +1 -1
  75. package/dist/core/agent-session.js.map +1 -1
  76. package/dist/core/compaction/branch-summarization.d.ts +2 -2
  77. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  78. package/dist/core/compaction/branch-summarization.js.map +1 -1
  79. package/dist/core/compaction/compaction.d.ts +3 -3
  80. package/dist/core/compaction/compaction.d.ts.map +1 -1
  81. package/dist/core/compaction/compaction.js.map +1 -1
  82. package/dist/core/export-html/tool-renderer.d.ts.map +1 -1
  83. package/dist/core/export-html/tool-renderer.js.map +1 -1
  84. package/dist/core/extensions/loader.d.ts +3 -2
  85. package/dist/core/extensions/loader.d.ts.map +1 -1
  86. package/dist/core/extensions/loader.js +24 -12
  87. package/dist/core/extensions/loader.js.map +1 -1
  88. package/dist/core/extensions/runner.d.ts.map +1 -1
  89. package/dist/core/extensions/runner.js +6 -0
  90. package/dist/core/extensions/runner.js.map +1 -1
  91. package/dist/core/extensions/types.d.ts +28 -17
  92. package/dist/core/extensions/types.d.ts.map +1 -1
  93. package/dist/core/extensions/types.js.map +1 -1
  94. package/dist/core/package-manager.d.ts +1 -0
  95. package/dist/core/package-manager.d.ts.map +1 -1
  96. package/dist/core/package-manager.js +65 -28
  97. package/dist/core/package-manager.js.map +1 -1
  98. package/dist/core/resource-loader.d.ts.map +1 -1
  99. package/dist/core/resource-loader.js +13 -5
  100. package/dist/core/resource-loader.js.map +1 -1
  101. package/dist/core/sdk.d.ts +3 -3
  102. package/dist/core/sdk.d.ts.map +1 -1
  103. package/dist/core/sdk.js.map +1 -1
  104. package/dist/core/session-manager.d.ts.map +1 -1
  105. package/dist/core/session-manager.js +1 -1
  106. package/dist/core/session-manager.js.map +1 -1
  107. package/dist/core/settings-manager.d.ts +2 -0
  108. package/dist/core/settings-manager.d.ts.map +1 -1
  109. package/dist/core/settings-manager.js.map +1 -1
  110. package/dist/core/slash-commands.d.ts.map +1 -1
  111. package/dist/core/slash-commands.js +1 -1
  112. package/dist/core/slash-commands.js.map +1 -1
  113. package/dist/core/system-prompt.d.ts.map +1 -1
  114. package/dist/core/system-prompt.js +5 -3
  115. package/dist/core/system-prompt.js.map +1 -1
  116. package/dist/core/tools/ask-user-question/view/components/preview/preview-block-renderer.d.ts +1 -1
  117. package/dist/core/tools/ask-user-question/view/components/preview/preview-block-renderer.d.ts.map +1 -1
  118. package/dist/core/tools/ask-user-question/view/components/preview/preview-block-renderer.js +1 -1
  119. package/dist/core/tools/ask-user-question/view/components/preview/preview-block-renderer.js.map +1 -1
  120. package/dist/core/tools/ask-user-question/view/dialog-builder.d.ts +8 -8
  121. package/dist/core/tools/ask-user-question/view/dialog-builder.d.ts.map +1 -1
  122. package/dist/core/tools/ask-user-question/view/dialog-builder.js +6 -6
  123. package/dist/core/tools/ask-user-question/view/dialog-builder.js.map +1 -1
  124. package/dist/core/tools/bash.d.ts.map +1 -1
  125. package/dist/core/tools/bash.js +1 -1
  126. package/dist/core/tools/bash.js.map +1 -1
  127. package/dist/core/tools/find.d.ts.map +1 -1
  128. package/dist/core/tools/find.js +1 -1
  129. package/dist/core/tools/find.js.map +1 -1
  130. package/dist/core/tools/grep.d.ts.map +1 -1
  131. package/dist/core/tools/grep.js +7 -4
  132. package/dist/core/tools/grep.js.map +1 -1
  133. package/dist/core/tools/index.d.ts +3 -2
  134. package/dist/core/tools/index.d.ts.map +1 -1
  135. package/dist/core/tools/index.js.map +1 -1
  136. package/dist/core/tools/ls.d.ts.map +1 -1
  137. package/dist/core/tools/ls.js +3 -2
  138. package/dist/core/tools/ls.js.map +1 -1
  139. package/dist/core/tools/read.d.ts.map +1 -1
  140. package/dist/core/tools/read.js +2 -2
  141. package/dist/core/tools/read.js.map +1 -1
  142. package/dist/core/tools/render-utils.d.ts +2 -1
  143. package/dist/core/tools/render-utils.d.ts.map +1 -1
  144. package/dist/core/tools/render-utils.js.map +1 -1
  145. package/dist/core/tools/todos.d.ts.map +1 -1
  146. package/dist/core/tools/todos.js +1 -1
  147. package/dist/core/tools/todos.js.map +1 -1
  148. package/dist/core/tools/tool-definition-wrapper.d.ts +4 -3
  149. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
  150. package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
  151. package/dist/core/tools/write.d.ts.map +1 -1
  152. package/dist/core/tools/write.js +1 -1
  153. package/dist/core/tools/write.js.map +1 -1
  154. package/dist/index.d.ts +2 -1
  155. package/dist/index.d.ts.map +1 -1
  156. package/dist/index.js +2 -1
  157. package/dist/index.js.map +1 -1
  158. package/dist/main.d.ts.map +1 -1
  159. package/dist/main.js +2 -2
  160. package/dist/main.js.map +1 -1
  161. package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  162. package/dist/modes/interactive/components/assistant-message.js +3 -3
  163. package/dist/modes/interactive/components/assistant-message.js.map +1 -1
  164. package/dist/modes/interactive/components/bash-execution.d.ts.map +1 -1
  165. package/dist/modes/interactive/components/bash-execution.js +3 -3
  166. package/dist/modes/interactive/components/bash-execution.js.map +1 -1
  167. package/dist/modes/interactive/components/branch-summary-message.d.ts.map +1 -1
  168. package/dist/modes/interactive/components/branch-summary-message.js +1 -1
  169. package/dist/modes/interactive/components/branch-summary-message.js.map +1 -1
  170. package/dist/modes/interactive/components/chat-message-renderer.d.ts +2 -1
  171. package/dist/modes/interactive/components/chat-message-renderer.d.ts.map +1 -1
  172. package/dist/modes/interactive/components/chat-message-renderer.js.map +1 -1
  173. package/dist/modes/interactive/components/compaction-summary-message.d.ts.map +1 -1
  174. package/dist/modes/interactive/components/compaction-summary-message.js +1 -1
  175. package/dist/modes/interactive/components/compaction-summary-message.js.map +1 -1
  176. package/dist/modes/interactive/components/config-selector.d.ts.map +1 -1
  177. package/dist/modes/interactive/components/config-selector.js +1 -1
  178. package/dist/modes/interactive/components/config-selector.js.map +1 -1
  179. package/dist/modes/interactive/components/custom-editor.d.ts +3 -0
  180. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  181. package/dist/modes/interactive/components/custom-editor.js +13 -3
  182. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  183. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  184. package/dist/modes/interactive/components/footer.js +1 -1
  185. package/dist/modes/interactive/components/footer.js.map +1 -1
  186. package/dist/modes/interactive/components/index.d.ts +2 -1
  187. package/dist/modes/interactive/components/index.d.ts.map +1 -1
  188. package/dist/modes/interactive/components/index.js +2 -1
  189. package/dist/modes/interactive/components/index.js.map +1 -1
  190. package/dist/modes/interactive/components/keybinding-hints.d.ts +1 -0
  191. package/dist/modes/interactive/components/keybinding-hints.d.ts.map +1 -1
  192. package/dist/modes/interactive/components/keybinding-hints.js +47 -5
  193. package/dist/modes/interactive/components/keybinding-hints.js.map +1 -1
  194. package/dist/modes/interactive/components/login-dialog.d.ts.map +1 -1
  195. package/dist/modes/interactive/components/login-dialog.js +5 -5
  196. package/dist/modes/interactive/components/login-dialog.js.map +1 -1
  197. package/dist/modes/interactive/components/model-selector.d.ts +3 -3
  198. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  199. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  200. package/dist/modes/interactive/components/scoped-models-selector.d.ts +2 -2
  201. package/dist/modes/interactive/components/scoped-models-selector.d.ts.map +1 -1
  202. package/dist/modes/interactive/components/scoped-models-selector.js +7 -7
  203. package/dist/modes/interactive/components/scoped-models-selector.js.map +1 -1
  204. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  205. package/dist/modes/interactive/components/session-selector.js +8 -8
  206. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  207. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  208. package/dist/modes/interactive/components/settings-selector.js +3 -3
  209. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  210. package/dist/modes/interactive/components/skill-invocation-message.d.ts.map +1 -1
  211. package/dist/modes/interactive/components/skill-invocation-message.js +2 -2
  212. package/dist/modes/interactive/components/skill-invocation-message.js.map +1 -1
  213. package/dist/modes/interactive/components/tool-execution.d.ts +10 -12
  214. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  215. package/dist/modes/interactive/components/tool-execution.js +3 -3
  216. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  217. package/dist/modes/interactive/components/working-status.d.ts +25 -0
  218. package/dist/modes/interactive/components/working-status.d.ts.map +1 -0
  219. package/dist/modes/interactive/components/working-status.js +28 -0
  220. package/dist/modes/interactive/components/working-status.js.map +1 -0
  221. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  222. package/dist/modes/interactive/interactive-mode.js +8 -7
  223. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  224. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  225. package/dist/modes/rpc/rpc-mode.js +8 -0
  226. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  227. package/dist/modes/rpc/rpc-types.d.ts +5 -5
  228. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  229. package/dist/modes/rpc/rpc-types.js.map +1 -1
  230. package/dist/utils/tools-manager.d.ts.map +1 -1
  231. package/dist/utils/tools-manager.js.map +1 -1
  232. package/docs/development.md +2 -2
  233. package/docs/extensions.md +7 -7
  234. package/docs/packages.md +11 -8
  235. package/docs/quickstart.md +2 -2
  236. package/docs/rpc.md +1 -1
  237. package/docs/sdk.md +14 -11
  238. package/docs/session-format.md +1 -1
  239. package/docs/sessions.md +10 -10
  240. package/docs/settings.md +1 -1
  241. package/docs/terminal-setup.md +9 -9
  242. package/docs/tmux.md +10 -10
  243. package/docs/tui.md +2 -2
  244. package/docs/usage.md +9 -9
  245. package/package.json +6 -1
@@ -19,13 +19,15 @@
19
19
  * - **Running** stage with a live stream: Enter calls `handle.steer(text)`
20
20
  * (interrupt mid-turn). Ctrl+F always queues a follow-up via
21
21
  * `handle.followUp(text)`.
22
- * - **Ctrl+P** calls `handle.pause()`; while paused, Enter calls
23
- * `handle.resume(text)`.
24
- * - **Ctrl+D** detaches (back to graph); **Escape** closes the popup.
22
+ * - **Escape** mirrors the main coding-agent chat interrupt path for active
23
+ * live stages: it requests a controlled pause/abort while keeping the
24
+ * composer active. While paused, Enter calls `handle.resume(text)`.
25
+ * - **Ctrl+D** detaches (back to graph); **Escape** closes the popup when idle.
25
26
  * - **Blocked** stage: keystrokes absorbed; BLOCKED banner names the
26
27
  * upstream awaiter.
27
- * - **Settled** stage (no handle, completed/failed): editor renders in a
28
- * disabled visual state and the hint strip collapses to back/close.
28
+ * - **Settled** stage with a live handle remains a normal chat session:
29
+ * Enter sends `handle.prompt(text)` and Escape interrupts any active
30
+ * post-stage response without mutating workflow dependencies.
29
31
  *
30
32
  * cross-ref:
31
33
  * - ui/stage-chat-mockup.html (canonical visual)
@@ -39,25 +41,34 @@
39
41
  import {
40
42
  ChatTranscriptComponent,
41
43
  CustomEditor,
44
+ FooterComponent,
42
45
  ScrollableComponentViewport,
43
46
  SessionManager,
44
47
  LiveChatEntriesController,
45
- chatEntriesFromAgentMessages,
48
+ UsageMeterComponent,
49
+ WorkingStatusComponent,
50
+ pickWhimsicalWorkingMessage,
46
51
  renderChatMessageEntry,
47
52
  type AgentSession,
48
53
  type AgentSessionEvent,
49
54
  type ChatMessageEntry,
50
55
  type ChatMessageRenderOptions,
51
- type ChatTranscriptRole,
56
+ type ReadonlyFooterDataProvider,
52
57
  } from "@bastani/atomic";
53
- import { Box, Container, Spacer, Text } from "@earendil-works/pi-tui";
54
- import type { Component, EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui";
58
+ import { Box, Spacer, Text } from "@earendil-works/pi-tui";
59
+ import type {
60
+ Component,
61
+ EditorComponent,
62
+ EditorTheme,
63
+ Focusable,
64
+ TUI,
65
+ } from "@earendil-works/pi-tui";
55
66
  import type { Store } from "../shared/store.js";
56
67
  import type { StageNotice, StageSnapshot } from "../shared/store-types.js";
57
68
  import type { GraphTheme } from "./graph-theme.js";
58
69
  import type { StageControlHandle } from "../runs/foreground/stage-control-registry.js";
59
70
  import { BOLD, RESET, hexBg, hexToAnsi, lerpColor } from "./color-utils.js";
60
- import { truncateToWidth, visibleWidth } from "./text-helpers.js";
71
+ import { matchesKey, truncateToWidth, visibleWidth } from "./text-helpers.js";
61
72
 
62
73
  // ---------------------------------------------------------------------------
63
74
  // Options & types
@@ -85,13 +96,21 @@ export interface StageChatViewOpts {
85
96
  piTui?: TUI;
86
97
  piKeybindings?: unknown;
87
98
  /** Currently installed host editor factory, inherited from extension `ctx.ui.setEditorComponent()`. */
88
- piEditorFactory?: (tui: TUI, theme: EditorTheme, keybindings: unknown) => EditorComponent;
99
+ piEditorFactory?: (
100
+ tui: TUI,
101
+ theme: EditorTheme,
102
+ keybindings: unknown,
103
+ ) => EditorComponent;
89
104
  /** Parent chat rendering settings and extension renderers inherited from the host UI. */
90
- getChatRenderSettings?: () => Partial<Omit<ChatMessageRenderOptions, "ui" | "cwd" | "markdownTheme">> | undefined;
105
+ getChatRenderSettings?: () =>
106
+ | Partial<Omit<ChatMessageRenderOptions, "ui" | "cwd" | "markdownTheme">>
107
+ | undefined;
108
+ /** Parent footer data provider inherited from the host UI for core footer/usage rendering. */
109
+ footerData?: ReadonlyFooterDataProvider;
91
110
  /**
92
111
  * Optional accessor returning the current terminal row count. The chat
93
112
  * surface expands its body band to roughly `viewportRows` minus the fixed
94
- * header / loader / editor / footer / hint rows so the popup fills the
113
+ * header / loader / editor / footer rows so the popup fills the
95
114
  * terminal under pi-tui's `width: "100%" / maxHeight: "100%"` geometry.
96
115
  * Returning `undefined` falls back to the constant 32-row frame.
97
116
  */
@@ -103,46 +122,16 @@ export interface StageChatViewOpts {
103
122
  * that read `_transcript` (tests, future serialisers) can recover the
104
123
  * canonical user-visible string without knowing about the Pi-box payload.
105
124
  */
106
- interface BaseEntry {
107
- readonly role: ChatTranscriptRole;
108
- readonly text: string;
109
- }
110
- interface UserEntry extends BaseEntry {
111
- readonly role: "user";
112
- }
113
- interface AssistantEntry extends BaseEntry {
114
- readonly role: "assistant";
115
- }
116
- interface ThinkingEntry extends BaseEntry {
117
- readonly role: "thinking";
118
- }
119
- interface SystemEntry extends BaseEntry {
120
- readonly role: "system";
121
- }
122
- interface ToolEntry extends BaseEntry {
123
- readonly role: "tool";
124
- readonly name: string;
125
- readonly toolCallId?: string;
126
- readonly args?: string;
127
- readonly output?: string;
128
- readonly state: "pending" | "success" | "error";
129
- }
130
- interface NoticeEntry extends BaseEntry {
125
+ interface NoticeEntry {
131
126
  readonly role: "notice";
127
+ readonly text: string;
132
128
  readonly noticeId: string;
133
129
  readonly kind: StageNotice["kind"];
134
130
  readonly value: string;
135
131
  readonly from?: string;
136
132
  readonly meta?: string;
137
133
  }
138
- type TranscriptEntry =
139
- | UserEntry
140
- | AssistantEntry
141
- | ThinkingEntry
142
- | SystemEntry
143
- | ToolEntry
144
- | NoticeEntry
145
- | ChatMessageEntry;
134
+ type TranscriptEntry = NoticeEntry | ChatMessageEntry;
146
135
  type AgentSnapshotMessage = AgentSession["messages"][number];
147
136
 
148
137
  // ---------------------------------------------------------------------------
@@ -160,15 +149,13 @@ const VIEW_LINE_COUNT = 32;
160
149
  const HEADER_ROWS = 1;
161
150
  /** Single dim rule between header and body. */
162
151
  const SEP_ROWS = 1;
163
- /** Footer: two dim lines. */
164
- const FOOTER_ROWS = 2;
165
- /** Hint strip: dashed rule + key bindings line. */
166
- const HINTS_ROWS = 2;
167
-
168
152
  /** Spinner glyphs — Braille spinner at 80ms per frame. */
169
153
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
170
154
  /** Pi's Loader advances at 80ms; use the same cadence for embedded stage chats. */
171
155
  const ANIMATION_FRAME_MS = 80;
156
+ const STREAMING_RENDER_THROTTLE_MS = 80;
157
+ const STREAMING_TEXT_TAIL_LINES = 240;
158
+ const STREAMING_TEXT_TAIL_CHARS = 16_000;
172
159
 
173
160
  const ITALIC = "\x1b[3m";
174
161
  const FG_RESET = "\x1b[39m";
@@ -179,7 +166,8 @@ const ITALIC_RESET = "\x1b[23m";
179
166
  // StageChatView
180
167
  // ---------------------------------------------------------------------------
181
168
 
182
- export class StageChatView implements Component {
169
+ export class StageChatView implements Component, Focusable {
170
+ focused = true;
183
171
  private store: Store;
184
172
  private theme: GraphTheme;
185
173
  private runId: string;
@@ -191,7 +179,10 @@ export class StageChatView implements Component {
191
179
  private requestRender: (() => void) | undefined;
192
180
  private getViewportRows?: () => number | undefined;
193
181
  private editor: EditorComponent | undefined;
194
- private getChatRenderSettings?: () => Partial<Omit<ChatMessageRenderOptions, "ui" | "cwd" | "markdownTheme">> | undefined;
182
+ private getChatRenderSettings?: () =>
183
+ | Partial<Omit<ChatMessageRenderOptions, "ui" | "cwd" | "markdownTheme">>
184
+ | undefined;
185
+ private footerData?: ReadonlyFooterDataProvider;
195
186
 
196
187
  private inputBuffer = "";
197
188
  private transcript: TranscriptEntry[] = [];
@@ -204,15 +195,14 @@ export class StageChatView implements Component {
204
195
  private attachedAt = Date.now();
205
196
  /** True after SDK `agent_start` until `agent_end`; mirrors Pi's working-loader lifecycle. */
206
197
  private sdkBusy = false;
207
- /** Stable row pointers for the current streaming assistant message. */
208
- private streamingAssistantIndex: number | undefined;
209
- private streamingThinkingIndex: number | undefined;
210
- /** Stable tool-call rows keyed exactly like Pi's `pendingTools` map. */
211
- private toolEntryIndexes = new Map<string, number>();
198
+ /** Pi-style per-turn working message, populated from coding-agent's message picker. */
199
+ private workingMessage: string | undefined;
212
200
  /** User rows optimistically appended by this embedded editor, de-duped on SDK echo. */
213
201
  private optimisticUserSignatures = new Set<string>();
214
202
  /** Chat-mode repaint driver for Pi-style loaders/spinners. */
215
203
  private animationTimer: ReturnType<typeof setInterval> | undefined;
204
+ /** Coalesces high-frequency SDK deltas while the fixed overlay is streaming. */
205
+ private renderThrottleTimer: ReturnType<typeof setTimeout> | undefined;
216
206
  /** Scrollable fixed-height body viewport for attached chat history. */
217
207
  private bodyViewport = new ScrollableComponentViewport();
218
208
  private liveChat: LiveChatEntriesController;
@@ -232,8 +222,13 @@ export class StageChatView implements Component {
232
222
  this.requestRender = opts.requestRender;
233
223
  this.getViewportRows = opts.getViewportRows;
234
224
  this.getChatRenderSettings = opts.getChatRenderSettings;
225
+ this.footerData = opts.footerData;
235
226
  this.liveChat = new LiveChatEntriesController(this.transcript);
236
- this.editor = this._createEditor(opts.piTui, opts.piKeybindings, opts.piEditorFactory);
227
+ this.editor = this._createEditor(
228
+ opts.piTui,
229
+ opts.piKeybindings,
230
+ opts.piEditorFactory,
231
+ );
237
232
 
238
233
  // Seed transcript from the live SDK session at attach time, plus any
239
234
  // stage notices the workflow body has already recorded.
@@ -264,7 +259,7 @@ export class StageChatView implements Component {
264
259
  this._unsubscribeHandle = this.handle.subscribe((event) => {
265
260
  const changed = this._appendEvent(event);
266
261
  this._syncAnimationTick();
267
- if (changed) this.requestRender?.();
262
+ if (changed) this._requestEventRender();
268
263
  });
269
264
  }
270
265
  this._syncAnimationTick();
@@ -273,16 +268,28 @@ export class StageChatView implements Component {
273
268
  private _createEditor(
274
269
  tui: TUI | undefined,
275
270
  keybindings: unknown,
276
- editorFactory: ((tui: TUI, theme: EditorTheme, keybindings: unknown) => EditorComponent) | undefined,
271
+ editorFactory:
272
+ | ((
273
+ tui: TUI,
274
+ theme: EditorTheme,
275
+ keybindings: unknown,
276
+ ) => EditorComponent)
277
+ | undefined,
277
278
  ): EditorComponent | undefined {
278
279
  if (!tui || !keybindings) return undefined;
279
280
  const editorTheme = editorThemeFromGraphTheme(this.theme);
280
- const editor = this._createInheritedEditor(tui, editorTheme, keybindings, editorFactory) ??
281
+ const editor =
282
+ this._createInheritedEditor(
283
+ tui,
284
+ editorTheme,
285
+ keybindings,
286
+ editorFactory,
287
+ ) ??
281
288
  new CustomEditor(
282
289
  tui,
283
290
  editorTheme,
284
291
  keybindings as ConstructorParameters<typeof CustomEditor>[2],
285
- { paddingX: 1, autocompleteMaxVisible: 5 },
292
+ { paddingX: 0, autocompleteMaxVisible: 5 },
286
293
  );
287
294
  editor.onChange = (text) => {
288
295
  this.inputBuffer = text;
@@ -297,7 +304,13 @@ export class StageChatView implements Component {
297
304
  tui: TUI,
298
305
  editorTheme: EditorTheme,
299
306
  keybindings: unknown,
300
- editorFactory: ((tui: TUI, theme: EditorTheme, keybindings: unknown) => EditorComponent) | undefined,
307
+ editorFactory:
308
+ | ((
309
+ tui: TUI,
310
+ theme: EditorTheme,
311
+ keybindings: unknown,
312
+ ) => EditorComponent)
313
+ | undefined,
301
314
  ): EditorComponent | undefined {
302
315
  if (!editorFactory) return undefined;
303
316
  try {
@@ -316,14 +329,17 @@ export class StageChatView implements Component {
316
329
  this.liveChat.appendMessages(this.handle.messages);
317
330
  }
318
331
 
319
- private _snapshotMessagesFromSessionFile(stage: StageSnapshot | undefined): void {
332
+ private _snapshotMessagesFromSessionFile(
333
+ stage: StageSnapshot | undefined,
334
+ ): void {
320
335
  if (this.transcript.length > 0) return;
321
336
  const sessionFile = this.handle?.sessionFile ?? stage?.sessionFile;
322
337
  if (sessionFile === undefined) return;
323
338
 
324
339
  let messages: readonly AgentSnapshotMessage[];
325
340
  try {
326
- messages = SessionManager.open(sessionFile).buildSessionContext().messages as readonly AgentSnapshotMessage[];
341
+ messages = SessionManager.open(sessionFile).buildSessionContext()
342
+ .messages as readonly AgentSnapshotMessage[];
327
343
  } catch {
328
344
  return;
329
345
  }
@@ -339,103 +355,55 @@ export class StageChatView implements Component {
339
355
  if (type === "message_start") {
340
356
  const message = (event as { message?: unknown }).message;
341
357
  if (isUserMessageLike(message)) {
342
- const signature = userMessageSignature(extractMessageText(message.content));
358
+ const signature = userMessageSignature(
359
+ extractMessageText(message.content),
360
+ );
343
361
  if (this.optimisticUserSignatures.delete(signature)) return false;
344
362
  }
345
363
  }
346
364
  if (isSharedLiveChatEvent(type)) {
347
- return this.liveChat.applyEvent(event);
365
+ const changed = this.liveChat.applyEvent(event);
366
+ const toolCallEvent = assistantToolCallEvent(event);
367
+ const changedByToolCall = toolCallEvent !== undefined
368
+ ? this.liveChat.applyEvent(toolCallEvent)
369
+ : false;
370
+ return changed || changedByToolCall;
348
371
  }
349
372
  switch (type) {
350
373
  case "agent_start":
351
374
  this.sdkBusy = true;
352
- this.toolEntryIndexes.clear();
353
375
  this.liveChat.clearPendingTools();
354
376
  this.statusMessage = "";
355
377
  return true;
356
378
 
357
379
  case "agent_end":
358
380
  this.sdkBusy = false;
359
- this.streamingAssistantIndex = undefined;
360
- this.streamingThinkingIndex = undefined;
381
+ this.workingMessage = undefined;
361
382
  this.liveChat.clearPendingTools();
362
383
  this.statusMessage = "";
363
384
  return true;
364
385
 
365
- case "message_start":
366
- return this._handleMessageStart((event as { message?: unknown }).message);
367
-
368
- case "message_update":
369
- return this._handleMessageUpdate(event);
370
-
371
- case "message_end":
372
- return this._handleMessageEnd((event as { message?: unknown }).message);
373
-
374
- case "tool_execution_start": {
375
- const payload = event as { toolCallId?: unknown; toolName?: unknown; args?: unknown };
376
- const name = typeof payload.toolName === "string" ? payload.toolName : "tool";
377
- const toolCallId = typeof payload.toolCallId === "string" ? payload.toolCallId : undefined;
378
- const args = summariseArgs(payload.args);
379
- this._upsertToolEntry({ toolCallId, name, args, state: "pending" });
380
- return true;
381
- }
382
-
383
- case "tool_execution_update": {
384
- const payload = event as { toolCallId?: unknown; toolName?: unknown; partialResult?: unknown };
385
- const partialOutput = extractToolResultText(payload.partialResult);
386
- if (!partialOutput) return false;
387
- this._upsertToolEntry({
388
- toolCallId: typeof payload.toolCallId === "string" ? payload.toolCallId : undefined,
389
- name: typeof payload.toolName === "string" ? payload.toolName : "tool",
390
- output: partialOutput,
391
- state: "pending",
392
- });
386
+ case "turn_start":
387
+ this.workingMessage = pickWhimsicalWorkingMessage();
393
388
  return true;
394
- }
395
389
 
396
- case "tool_execution_end": {
397
- const payload = event as { toolCallId?: unknown; toolName?: unknown; result?: unknown; isError?: unknown };
398
- const toolCallId = typeof payload.toolCallId === "string" ? payload.toolCallId : undefined;
399
- const output = extractToolResultText(payload.result);
400
- this._upsertToolEntry({
401
- toolCallId,
402
- name: typeof payload.toolName === "string" ? payload.toolName : "tool",
403
- output,
404
- state: payload.isError === true ? "error" : "success",
405
- });
406
- if (toolCallId) this.toolEntryIndexes.delete(toolCallId);
390
+ case "turn_end":
391
+ this.workingMessage = undefined;
407
392
  return true;
408
- }
409
393
 
394
+ // Compatibility with older/headless shims that predate the SDK's
395
+ // tool_execution_* events. Project these shims into coding-agent's live
396
+ // controller rather than maintaining a second workflow tool renderer.
410
397
  case "tool_call":
411
- case "tool_use": {
412
- const name = String((event as { name?: unknown }).name ?? "tool");
413
- const args = summariseArgs((event as { input?: unknown }).input);
414
- this._upsertToolEntry({ name, args, state: "pending" });
415
- return true;
416
- }
398
+ case "tool_use":
399
+ return this.liveChat.applyEvent(legacyToolStartEvent(event));
417
400
 
418
- case "tool_result": {
419
- const name = String((event as { name?: unknown }).name ?? "tool");
420
- const rawOutput = (event as { output?: unknown }).output;
421
- const output = typeof rawOutput === "string" ? rawOutput : extractMessageText(rawOutput);
422
- this._upsertToolEntry({
423
- name,
424
- output,
425
- state: Boolean((event as { isError?: unknown }).isError) ? "error" : "success",
426
- });
427
- return true;
428
- }
401
+ case "tool_result":
402
+ return this.liveChat.applyEvent(legacyToolResultEvent(event));
429
403
 
430
404
  case "thinking_delta":
431
- case "thinking": {
432
- const delta = String(
433
- (event as { delta?: unknown }).delta ?? (event as { text?: unknown }).text ?? "",
434
- );
435
- if (!delta) return false;
436
- this._appendTextDelta("thinking", delta);
437
- return true;
438
- }
405
+ case "thinking":
406
+ return this.liveChat.applyEvent(legacyThinkingEvent(event));
439
407
 
440
408
  case "compaction_start":
441
409
  this.sdkBusy = true;
@@ -461,115 +429,6 @@ export class StageChatView implements Component {
461
429
  }
462
430
  }
463
431
 
464
- private _handleMessageStart(message: unknown): boolean {
465
- if (!isMessageLike(message)) return false;
466
- if (message.role === "assistant") {
467
- this.streamingAssistantIndex = undefined;
468
- this.streamingThinkingIndex = undefined;
469
- return this._updateAssistantFromMessage(message);
470
- }
471
-
472
- const entry = transcriptEntryFromSnapshotMessage(message as AgentSnapshotMessage);
473
- if (!entry) return false;
474
- if (entry.role === "user") {
475
- const signature = userMessageSignature(entry.text);
476
- if (this.optimisticUserSignatures.delete(signature)) return false;
477
- }
478
- this.transcript.push(entry);
479
- return true;
480
- }
481
-
482
- private _handleMessageUpdate(event: AgentSessionEvent): boolean {
483
- const message = (event as { message?: unknown }).message;
484
- const hasAssistantSnapshot = isMessageLike(message) && message.role === "assistant";
485
- const snapshotHasPayload = hasAssistantSnapshot && assistantContentHasRenderablePayload(message.content);
486
- let changed = false;
487
- if (hasAssistantSnapshot) {
488
- changed = this._updateAssistantFromMessage(message) || changed;
489
- }
490
-
491
- const assistantEvent = (event as { assistantMessageEvent?: { type?: unknown; delta?: unknown } }).assistantMessageEvent;
492
- const streamType = String(assistantEvent?.type ?? "");
493
- const delta = typeof assistantEvent?.delta === "string" ? assistantEvent.delta : "";
494
- // Prefer Pi's full assistant message snapshot when it contains visible
495
- // payload; use deltas only for delta-only SDK shims/events.
496
- if (!changed && !snapshotHasPayload && streamType === "text_delta" && delta) {
497
- this._appendTextDelta("assistant", delta);
498
- changed = true;
499
- } else if (!changed && !snapshotHasPayload && streamType === "thinking_delta" && delta) {
500
- this._appendTextDelta("thinking", delta);
501
- changed = true;
502
- }
503
-
504
- return changed;
505
- }
506
-
507
- private _handleMessageEnd(message: unknown): boolean {
508
- let changed = false;
509
- if (isMessageLike(message) && message.role === "assistant") {
510
- changed = this._updateAssistantFromMessage(message) || changed;
511
- for (const [toolCallId, index] of this.toolEntryIndexes.entries()) {
512
- const entry = this.transcript[index];
513
- if (isLocalToolEntry(entry) && entry.state === "pending") {
514
- this.transcript[index] = { ...entry, text: entry.text };
515
- }
516
- this.toolEntryIndexes.set(toolCallId, index);
517
- }
518
- }
519
- this.streamingAssistantIndex = undefined;
520
- this.streamingThinkingIndex = undefined;
521
- return changed || isMessageLike(message);
522
- }
523
-
524
- private _updateAssistantFromMessage(message: { role?: unknown; content?: unknown; stopReason?: unknown; errorMessage?: unknown }): boolean {
525
- const projection = projectAssistantContent(message.content);
526
- let changed = false;
527
- if (projection.thinking) {
528
- changed = this._upsertStreamingText("thinking", projection.thinking) || changed;
529
- }
530
- if (projection.text) {
531
- changed = this._upsertStreamingText("assistant", projection.text) || changed;
532
- }
533
- const stopReason = typeof message.stopReason === "string" ? message.stopReason : "";
534
- if (stopReason === "aborted" || stopReason === "error") {
535
- const errorText = typeof message.errorMessage === "string" && message.errorMessage
536
- ? message.errorMessage
537
- : stopReason === "aborted"
538
- ? "Operation aborted"
539
- : "Unknown error";
540
- changed = this._failPendingToolEntries(errorText) || changed;
541
- if (!projection.toolCalls.length) {
542
- changed = this._upsertStreamingText("system", stopReason === "error" ? `Error: ${errorText}` : errorText) || changed;
543
- }
544
- }
545
- for (const toolCall of projection.toolCalls) {
546
- changed = this._upsertToolEntry(toolCall) || changed;
547
- }
548
- return changed;
549
- }
550
-
551
- private _upsertStreamingText(
552
- role: "assistant" | "thinking" | "system",
553
- text: string,
554
- ): boolean {
555
- if (!text) return false;
556
- if (role === "system") {
557
- this._upsertTextLastByRole("system", text);
558
- return true;
559
- }
560
- const index = role === "assistant" ? this.streamingAssistantIndex : this.streamingThinkingIndex;
561
- if (index !== undefined && isLocalTextRoleEntry(this.transcript[index], role)) {
562
- if (this.transcript[index]?.text === text) return false;
563
- this.transcript[index] = { role, text } as TranscriptEntry;
564
- return true;
565
- }
566
- this.transcript.push({ role, text } as TranscriptEntry);
567
- const nextIndex = this.transcript.length - 1;
568
- if (role === "assistant") this.streamingAssistantIndex = nextIndex;
569
- else this.streamingThinkingIndex = nextIndex;
570
- return true;
571
- }
572
-
573
432
  private _absorbStageNotices(stage: StageSnapshot | undefined): boolean {
574
433
  const notices = stage?.notices;
575
434
  if (!notices) return false;
@@ -591,89 +450,6 @@ export class StageChatView implements Component {
591
450
  return changed;
592
451
  }
593
452
 
594
- private _upsertTextLastByRole(
595
- role: "user" | "assistant" | "thinking" | "system",
596
- text: string,
597
- ): void {
598
- const last = this.transcript[this.transcript.length - 1];
599
- if (isLocalTextRoleEntry(last, role)) {
600
- this.transcript[this.transcript.length - 1] = { role, text } as TranscriptEntry;
601
- } else {
602
- this.transcript.push({ role, text } as TranscriptEntry);
603
- }
604
- }
605
-
606
- private _appendTextDelta(
607
- role: "assistant" | "thinking",
608
- delta: string,
609
- ): void {
610
- const index = role === "assistant" ? this.streamingAssistantIndex : this.streamingThinkingIndex;
611
- if (index !== undefined && isLocalTextRoleEntry(this.transcript[index], role)) {
612
- const current = this.transcript[index];
613
- this.transcript[index] = { role, text: current.text + delta } as TranscriptEntry;
614
- return;
615
- }
616
- this.transcript.push({ role, text: delta } as TranscriptEntry);
617
- const nextIndex = this.transcript.length - 1;
618
- if (role === "assistant") this.streamingAssistantIndex = nextIndex;
619
- else this.streamingThinkingIndex = nextIndex;
620
- }
621
-
622
- private _failPendingToolEntries(errorText: string): boolean {
623
- let changed = false;
624
- for (const [toolCallId, index] of this.toolEntryIndexes.entries()) {
625
- const entry = this.transcript[index];
626
- if (!isLocalToolEntry(entry) || entry.state !== "pending") continue;
627
- changed = this._upsertToolEntry({
628
- toolCallId,
629
- name: entry.name,
630
- output: errorText,
631
- state: "error",
632
- }) || changed;
633
- }
634
- this.toolEntryIndexes.clear();
635
- return changed;
636
- }
637
-
638
- private _upsertToolEntry(update: {
639
- toolCallId?: string;
640
- name: string;
641
- args?: string;
642
- output?: string;
643
- state: "pending" | "success" | "error";
644
- }): boolean {
645
- const mappedIndex = update.toolCallId ? this.toolEntryIndexes.get(update.toolCallId) : undefined;
646
- const index = mappedIndex ?? findToolEntryIndex(this.transcript, update.toolCallId, update.name);
647
- const existing = index !== undefined && index >= 0 ? this.transcript[index] : undefined;
648
- const previous = isLocalToolEntry(existing) ? existing : undefined;
649
- const output = update.output || previous?.output;
650
- const name = previous?.name ?? update.name;
651
- const args = update.args ?? previous?.args;
652
- const summary = output ? truncateToWidth(output.replace(/\s+/g, " "), 80) : "";
653
- const next: ToolEntry = {
654
- role: "tool",
655
- name,
656
- toolCallId: previous?.toolCallId ?? update.toolCallId,
657
- args,
658
- output,
659
- state: update.state,
660
- text: summary
661
- ? `← ${name} ${summary}`
662
- : args
663
- ? `→ ${name} ${args}`
664
- : `→ ${name}`,
665
- };
666
- if (previous && shallowToolEntryEqual(previous, next)) return false;
667
- if (index !== undefined && index >= 0) {
668
- this.transcript[index] = next;
669
- if (next.toolCallId) this.toolEntryIndexes.set(next.toolCallId, index);
670
- } else {
671
- this.transcript.push(next);
672
- if (next.toolCallId) this.toolEntryIndexes.set(next.toolCallId, this.transcript.length - 1);
673
- }
674
- return true;
675
- }
676
-
677
453
  private _currentStage(): StageSnapshot | undefined {
678
454
  const snap = this.store.snapshot();
679
455
  const run = snap.runs.find((r) => r.id === this.runId);
@@ -703,12 +479,12 @@ export class StageChatView implements Component {
703
479
  }
704
480
 
705
481
  private _hasPendingToolEntries(): boolean {
706
- return this.liveChat.pendingToolIds().length > 0 ||
707
- this.transcript.some((entry) => isLocalToolEntry(entry) && entry.state === "pending");
482
+ return this.liveChat.pendingToolIds().length > 0;
708
483
  }
709
484
 
710
485
  private _syncAnimationTick(): void {
711
- const shouldAnimate = this._isStreaming() || (this.sdkBusy && this._hasPendingToolEntries());
486
+ const shouldAnimate =
487
+ this._isStreaming() || (this.sdkBusy && this._hasPendingToolEntries());
712
488
  if (shouldAnimate && !this.animationTimer) {
713
489
  this.animationTimer = setInterval(() => {
714
490
  this.requestRender?.();
@@ -722,66 +498,68 @@ export class StageChatView implements Component {
722
498
  }
723
499
  }
724
500
 
501
+ private _requestEventRender(): void {
502
+ if (!this._isStreaming()) {
503
+ this.requestRender?.();
504
+ return;
505
+ }
506
+ if (this.renderThrottleTimer) return;
507
+ this.renderThrottleTimer = setTimeout(() => {
508
+ this.renderThrottleTimer = undefined;
509
+ this.requestRender?.();
510
+ }, STREAMING_RENDER_THROTTLE_MS);
511
+ this.renderThrottleTimer.unref?.();
512
+ }
513
+
725
514
  private _isBlocked(): boolean {
726
515
  return this._currentStage()?.status === "blocked";
727
516
  }
728
517
 
729
- private _isSettled(stage: StageSnapshot | undefined): boolean {
730
- if (!stage) return !this.handle;
731
- return stage.status === "completed" || stage.status === "failed";
518
+ private _isPaused(
519
+ stage: StageSnapshot | undefined = this._currentStage(),
520
+ ): boolean {
521
+ return this.localPaused || stage?.status === "paused";
732
522
  }
733
523
 
734
524
  // -------------------------------------------------------------------------
735
- // Top-level render — composes header / body / loader / editor / footer / hints
525
+ // Top-level render — composes header / body / usage / editor / footer
736
526
  // -------------------------------------------------------------------------
737
527
 
738
528
  render(width: number): string[] {
739
529
  const w = Math.max(40, width);
740
530
  const stage = this._currentStage();
741
531
  const blocked = this._isBlocked();
742
- const settled = this._isSettled(stage);
743
- const streaming = this._isStreaming() && !blocked && !settled;
744
- const paused = this.localPaused || stage?.status === "paused";
532
+ const streaming = this._isStreaming() && !blocked;
745
533
 
746
534
  const headerLines = this._renderHeader(w, stage);
747
535
  const sepLines = [this._sepRule(w)];
748
- const loaderLines = streaming ? this._renderLoader(w, stage) : [];
749
- // When the loader sits above the editor, the loader's bottom rule and
750
- // the editor's top rule collapse into a single shared divider — matches
751
- // the mockup's `pi-loader` + `pi-editor` stack and saves one row.
752
- const editorLines = this._renderEditor(w, {
753
- paused,
754
- streaming,
755
- settled,
756
- blocked,
757
- omitTopRule: loaderLines.length > 0,
758
- });
759
- const hintsLines = this._renderHints(w, { paused, streaming, settled });
536
+ const workingLines = this._renderWorkingStatus(w, stage, { streaming });
537
+ const usageLines = this._renderUsage(w);
538
+ const editorLines = this._renderEditor(w, blocked);
539
+ const footerLines = this._renderFooter(w);
760
540
 
761
541
  const fixed =
762
542
  HEADER_ROWS +
763
543
  SEP_ROWS +
764
- loaderLines.length +
544
+ workingLines.length +
545
+ usageLines.length +
765
546
  editorLines.length +
766
- FOOTER_ROWS +
767
- HINTS_ROWS;
547
+ footerLines.length;
768
548
  const totalRows = this._viewLineCount();
769
549
  const bodyBudget = Math.max(1, totalRows - fixed);
770
550
  this.bodyViewport.setVisibleRows(bodyBudget);
771
551
  if (blocked) this.bodyViewport.scrollToBottom();
772
552
  const bodyLines = blocked
773
553
  ? this._renderBlockedBody(w, bodyBudget, stage)
774
- : this._renderBody(w, bodyBudget, stage, { paused, streaming, settled });
775
- const footerLines = this._renderFooter(w, stage, { paused, streaming, settled });
776
-
554
+ : this._renderBody(w, bodyBudget);
777
555
  const lines = [
778
556
  ...headerLines,
779
557
  ...sepLines,
780
558
  ...bodyLines,
781
- ...loaderLines,
559
+ ...workingLines,
560
+ ...usageLines,
782
561
  ...editorLines,
783
562
  ...footerLines,
784
- ...hintsLines,
785
563
  ];
786
564
  while (lines.length < totalRows) lines.push(this._blank(w));
787
565
  if (lines.length > totalRows) lines.length = totalRows;
@@ -792,7 +570,10 @@ export class StageChatView implements Component {
792
570
  // Header
793
571
  // -------------------------------------------------------------------------
794
572
 
795
- private _renderHeader(width: number, stage: StageSnapshot | undefined): string[] {
573
+ private _renderHeader(
574
+ width: number,
575
+ stage: StageSnapshot | undefined,
576
+ ): string[] {
796
577
  const t = this.theme;
797
578
  const stageName = stage?.name ?? "stage";
798
579
  const status = stage?.status ?? (this.handle ? "pending" : "completed");
@@ -811,7 +592,11 @@ export class StageChatView implements Component {
811
592
  const pill = this._statusPill(status);
812
593
  const right = (meta ? paint(meta, t.dim) + " " : "") + pill.styled + " ";
813
594
 
814
- const leftW = visibleWidth(this.workflowName) + visibleWidth(stageName) + visibleWidth(" STAGE / ") + 1;
595
+ const leftW =
596
+ visibleWidth(this.workflowName) +
597
+ visibleWidth(stageName) +
598
+ visibleWidth(" STAGE / ") +
599
+ 1;
815
600
  const rightW = visibleWidth(meta) + (meta ? 2 : 0) + pill.width + 1;
816
601
  const gap = Math.max(1, width - leftW - rightW);
817
602
  return [left + " ".repeat(gap) + right];
@@ -836,11 +621,31 @@ export class StageChatView implements Component {
836
621
  const t = this.theme;
837
622
  const map: Record<string, { fg: string; bg: string; label: string }> = {
838
623
  pending: { fg: t.dim, bg: blendBg(t.bg, t.dim, 0.18), label: "pending" },
839
- running: { fg: t.accent, bg: blendBg(t.bg, t.accent, 0.18), label: "running" },
840
- paused: { fg: t.warning, bg: blendBg(t.bg, t.warning, 0.18), label: "paused" },
841
- blocked: { fg: t.warning, bg: blendBg(t.bg, t.warning, 0.18), label: "blocked" },
842
- completed: { fg: t.success, bg: blendBg(t.bg, t.success, 0.18), label: "completed" },
843
- failed: { fg: t.error, bg: blendBg(t.bg, t.error, 0.18), label: "failed" },
624
+ running: {
625
+ fg: t.accent,
626
+ bg: blendBg(t.bg, t.accent, 0.18),
627
+ label: "running",
628
+ },
629
+ paused: {
630
+ fg: t.warning,
631
+ bg: blendBg(t.bg, t.warning, 0.18),
632
+ label: "paused",
633
+ },
634
+ blocked: {
635
+ fg: t.warning,
636
+ bg: blendBg(t.bg, t.warning, 0.18),
637
+ label: "blocked",
638
+ },
639
+ completed: {
640
+ fg: t.success,
641
+ bg: blendBg(t.bg, t.success, 0.18),
642
+ label: "completed",
643
+ },
644
+ failed: {
645
+ fg: t.error,
646
+ bg: blendBg(t.bg, t.error, 0.18),
647
+ label: "failed",
648
+ },
844
649
  };
845
650
  const cfg = map[status] ?? map.pending!;
846
651
  const body = ` ● ${cfg.label} `;
@@ -858,25 +663,39 @@ export class StageChatView implements Component {
858
663
  // Body — welcome panel / banner + transcript / blocked
859
664
  // -------------------------------------------------------------------------
860
665
 
861
- private _renderBlockedBody(width: number, budget: number, stage: StageSnapshot | undefined): string[] {
666
+ private _renderBlockedBody(
667
+ width: number,
668
+ budget: number,
669
+ stage: StageSnapshot | undefined,
670
+ ): string[] {
862
671
  const t = this.theme;
863
672
  const upstream = stage?.blockedByStageId ?? "upstream stage";
864
673
  const lines: string[] = [];
865
674
  // Yellow banner — uses the same chrome vocabulary as paused/completed.
866
- lines.push(...this._bannerLines(width, "warning", "↑", "BLOCKED", `waiting on ${upstream}`));
675
+ lines.push(
676
+ ...this._bannerLines(
677
+ width,
678
+ "warning",
679
+ "↑",
680
+ "BLOCKED",
681
+ `waiting on ${upstream}`,
682
+ ),
683
+ );
867
684
  lines.push(this._blank(width));
868
685
  lines.push(
869
686
  ...new Text(
870
- paint("This stage is waiting for the upstream stage to resume.", t.textMuted),
687
+ paint(
688
+ "This stage is waiting for the upstream stage to resume.",
689
+ t.textMuted,
690
+ ),
871
691
  2,
872
692
  0,
873
693
  ).render(width),
874
694
  );
875
695
  lines.push(
876
696
  ...new Text(
877
- paint("Press ", t.textMuted) +
878
- paint("Ctrl+D", t.accent, { bold: true }) +
879
- paint(" to return to the graph.", t.textMuted),
697
+ paint("ctrl+d", t.accent, { bold: true }) +
698
+ paint(" return to graph", t.textMuted),
880
699
  2,
881
700
  0,
882
701
  ).render(width),
@@ -889,109 +708,31 @@ export class StageChatView implements Component {
889
708
  private _renderBody(
890
709
  width: number,
891
710
  budget: number,
892
- stage: StageSnapshot | undefined,
893
- flags: { paused: boolean; streaming: boolean; settled: boolean },
894
711
  ): string[] {
895
- // Empty + not paused + not settled + not streaming → welcome panel.
896
- const transcriptEmpty = this.transcript.length === 0;
897
- if (transcriptEmpty && !flags.paused && !flags.settled && !flags.streaming) {
898
- return this._fitToBudget(this._renderWelcome(width, stage), budget, width);
899
- }
900
-
901
712
  const components: Component[] = [];
902
- if (flags.paused) {
903
- components.push(
904
- this._banner(
905
- "warning",
906
- "❚❚",
907
- "PAUSED",
908
- "stopped between turns · type to resume, or Ctrl+P to release without input",
909
- ),
910
- );
911
- components.push(new Spacer(1));
912
- } else if (flags.settled && stage?.status === "completed") {
913
- components.push(this._banner("success", "✓", "COMPLETED", this._completedMeta(stage)));
914
- components.push(new Spacer(1));
915
- } else if (flags.settled && stage?.status === "failed") {
916
- components.push(
917
- this._banner(
918
- "error",
919
- "✗",
920
- "FAILED",
921
- stage?.error?.replace(/\s+/g, " ") ?? "stage exited with an error",
922
- ),
923
- );
924
- components.push(new Spacer(1));
925
- }
926
-
927
713
  // Base chat body: delegate transcript composition to the Pi-style
928
714
  // transcript component so the attached stage chat uses the same message
929
715
  // spacing and coding-agent message widgets as the main interactive chat.
930
716
  if (this.transcript.length > 0) {
931
717
  components.push(
932
- new ChatTranscriptComponent(this.transcript, (entry) => this._renderEntry(entry)),
718
+ new ChatTranscriptComponent(this.transcript, (entry) =>
719
+ this._renderEntry(entry),
720
+ ),
933
721
  );
934
722
  }
935
723
 
936
724
  // Stream a static status message (e.g. "pausing…") as a dim trailing row.
937
725
  if (this.statusMessage) {
938
726
  components.push(new Spacer(1));
939
- components.push(new Text(paint(this.statusMessage, this.theme.dim), 2, 0));
727
+ components.push(
728
+ new Text(paint(this.statusMessage, this.theme.dim), 2, 0),
729
+ );
940
730
  }
941
731
 
942
732
  this.bodyViewport.setComponents(components);
943
733
  return this.bodyViewport.render(width);
944
734
  }
945
735
 
946
- private _fitToBudget(lines: string[], budget: number, width: number): string[] {
947
- if (lines.length >= budget) return lines.slice(lines.length - budget);
948
- const out = lines.slice();
949
- while (out.length < budget) out.push(this._blank(width));
950
- return out;
951
- }
952
-
953
- // -------------------------------------------------------------------------
954
- // Welcome panel — first attach, no transcript yet
955
- // -------------------------------------------------------------------------
956
-
957
- private _renderWelcome(width: number, stage: StageSnapshot | undefined): string[] {
958
- const t = this.theme;
959
- const sessionId = this.handle?.sessionId ?? stage?.sessionId;
960
- const sessionFile = this.handle?.sessionFile ?? stage?.sessionFile;
961
- const status = stage?.status ?? "pending";
962
-
963
- const out: string[] = [];
964
- out.push(...new Spacer(1).render(width));
965
- out.push(centred(paint("▎", t.mauve, { bold: true }), width));
966
- out.push(
967
- centred(
968
- paint("Attached to ", t.text) +
969
- paint(this.workflowName, t.textMuted) +
970
- paint(" / ", t.dim) +
971
- paint(stage?.name ?? "stage", t.text, { bold: true }),
972
- width,
973
- ),
974
- );
975
- out.push(...new Spacer(1).render(width));
976
- const sub =
977
- "This stage is idle. Press ↵ to send the first prompt — the SDK session " +
978
- "will be created on submit. The workflow body keeps running in the " +
979
- "background; closing this overlay does not kill the run.";
980
- out.push(...new Text(paint(sub, t.textMuted), 4, 0).render(width));
981
- out.push(...new Spacer(1).render(width));
982
-
983
- const grid: Array<[string, string]> = [
984
- ["session", sessionId ? shortenId(sessionId) : "(not yet realised)"],
985
- ["status", status],
986
- ];
987
- if (sessionFile) grid.push(["session file", shortenFile(sessionFile)]);
988
- for (const [k, v] of grid) {
989
- const row = paint(k.padEnd(13), t.dim) + paint(v, t.text);
990
- out.push(...new Text(row, 8, 0).render(width));
991
- }
992
- return out;
993
- }
994
-
995
736
  // -------------------------------------------------------------------------
996
737
  // Transcript entry → pi/coding-agent Component. Stage chat deliberately uses
997
738
  // the same exported message/tool components as the main interactive chat
@@ -1000,56 +741,34 @@ export class StageChatView implements Component {
1000
741
 
1001
742
  private _renderEntry(entry: TranscriptEntry): Component {
1002
743
  if (isChatMessageEntry(entry)) {
1003
- return renderChatMessageEntry(entry, this._chatMessageRenderOptions());
1004
- }
1005
- switch (entry.role) {
1006
- case "user":
1007
- return renderChatMessageEntry(
1008
- { role: "user", kind: "user", text: entry.text },
1009
- this._chatMessageRenderOptions(),
1010
- );
1011
- case "assistant":
1012
- return renderChatMessageEntry(
1013
- { role: "assistant", kind: "assistant", message: assistantMessageForText(entry.text) },
1014
- this._chatMessageRenderOptions(),
1015
- );
1016
- case "thinking":
1017
- return renderChatMessageEntry(
1018
- { role: "assistant", kind: "assistant", message: assistantMessageForThinking(entry.text) },
1019
- this._chatMessageRenderOptions(),
1020
- );
1021
- case "tool":
1022
- return renderChatMessageEntry(this._toolEntryToChatMessage(entry), this._chatMessageRenderOptions());
1023
- case "notice":
1024
- return this._noticeRow(entry);
1025
- case "system":
1026
- return renderChatMessageEntry(
1027
- { role: "system", kind: "system", text: entry.text },
1028
- this._chatMessageRenderOptions(),
1029
- );
744
+ return renderChatMessageEntry(
745
+ this._streamingWindowedEntry(entry),
746
+ this._chatMessageRenderOptions(),
747
+ );
1030
748
  }
749
+ return this._noticeRow(entry);
1031
750
  }
1032
751
 
1033
- private _toolEntryToChatMessage(entry: ToolEntry): ChatMessageEntry {
1034
- const toolCallId = entry.toolCallId ?? `workflow-${entry.name}`;
752
+ private _streamingWindowedEntry(entry: ChatMessageEntry): ChatMessageEntry {
753
+ if (!this._isStreaming() || this.bodyViewport.getScrollFromBottom() !== 0) {
754
+ return entry;
755
+ }
756
+ if (entry.kind !== "assistant") return entry;
757
+ const content = entry.message.content.map((item) => {
758
+ if (item.type === "text") {
759
+ return { ...item, text: tailStreamingText(item.text) };
760
+ }
761
+ if (item.type === "thinking") {
762
+ return { ...item, thinking: tailStreamingText(item.thinking) };
763
+ }
764
+ return item;
765
+ });
1035
766
  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,
767
+ ...entry,
768
+ message: {
769
+ ...entry.message,
770
+ content,
771
+ },
1053
772
  };
1054
773
  }
1055
774
 
@@ -1058,7 +777,7 @@ export class StageChatView implements Component {
1058
777
  return {
1059
778
  ...inherited,
1060
779
  ui: this._toolTui(),
1061
- cwd: process.cwd(),
780
+ cwd: this.handle?.agentSession?.sessionManager.getCwd() ?? process.cwd(),
1062
781
  showImages: inherited?.showImages ?? true,
1063
782
  };
1064
783
  }
@@ -1094,8 +813,9 @@ export class StageChatView implements Component {
1094
813
  meta: string,
1095
814
  ): Component {
1096
815
  const t = this.theme;
1097
- const fg = kind === "warning" ? t.warning : kind === "success" ? t.success : t.error;
1098
- const bg = blendBg(t.bg, fg, 0.10);
816
+ const fg =
817
+ kind === "warning" ? t.warning : kind === "success" ? t.success : t.error;
818
+ const bg = blendBg(t.bg, fg, 0.1);
1099
819
  const head =
1100
820
  paintOnFill(glyph, fg, { bold: true }) +
1101
821
  " " +
@@ -1121,215 +841,98 @@ export class StageChatView implements Component {
1121
841
  return this._banner(kind, glyph, label, meta).render(width);
1122
842
  }
1123
843
 
1124
- // -------------------------------------------------------------------------
1125
- // Loader — top rule + spinner row + bottom rule
1126
- // -------------------------------------------------------------------------
1127
-
1128
- private _renderLoader(width: number, stage: StageSnapshot | undefined): string[] {
1129
- const t = this.theme;
1130
- const rule = hexToAnsi(t.border) + "─".repeat(width) + RESET;
1131
- const dur = stageDurationText(stage);
1132
- const msg = `Working${dur ? " · " + dur : ""}`;
1133
- const escapeHint = paint("Esc", t.text, { bold: true }) + " " + paint("interrupt", t.dim);
1134
- const left = " " + paint(spinnerFrame(), t.accent, { bold: true }) + " " + paint(msg, t.textMuted) + " ";
1135
- const leftW = visibleWidth(spinnerFrame()) + 4 + visibleWidth(msg);
1136
- const rightW = visibleWidth("Esc interrupt");
1137
- const gap = Math.max(1, width - leftW - rightW - 2);
1138
- const body = left + " ".repeat(gap) + escapeHint + " ";
1139
- // No closing rule — the editor's top rule (or the editor's body when
1140
- // `omitTopRule: true`) sits directly underneath and provides the divider.
1141
- return [rule, body];
1142
- }
1143
-
1144
844
  // -------------------------------------------------------------------------
1145
845
  // Editor — top rule + ` ❯ … ` + bottom rule
1146
846
  // -------------------------------------------------------------------------
1147
847
 
1148
- private _renderEditor(
1149
- width: number,
1150
- flags: {
1151
- paused: boolean;
1152
- streaming: boolean;
1153
- settled: boolean;
1154
- blocked: boolean;
1155
- /**
1156
- * When `true`, drop the editor's top rule — the loader directly above
1157
- * already paints a horizontal rule and we don't want a doubled border.
1158
- */
1159
- omitTopRule: boolean;
1160
- },
1161
- ): string[] {
848
+ private _renderEditor(width: number, blocked: boolean): string[] {
1162
849
  const t = this.theme;
1163
- // Disabled (settled or blocked) uses surface1 rules + dim placeholder.
1164
- const disabled = flags.settled || flags.blocked || !this.handle;
850
+ // Disabled only when no live chat handle exists or workflow dependencies
851
+ // are blocked. A settled attached stage remains a regular chat session.
852
+ const disabled = blocked || !this.handle;
853
+ const ruleHex = this._editorRuleColor(disabled);
1165
854
  if (!disabled && this.editor) {
855
+ setEditorFocused(this.editor, this.focused);
856
+ setEditorPlaceholder(this.editor, undefined);
857
+ setEditorBorderColor(this.editor, ruleHex);
1166
858
  return this.editor.render(width);
1167
859
  }
1168
- const ruleHex = disabled ? t.borderDim : t.border;
860
+ if (this.editor) setEditorFocused(this.editor, false);
1169
861
  const rule = hexToAnsi(ruleHex) + "─".repeat(width) + RESET;
1170
862
 
1171
863
  const glyphHex = disabled ? t.dim : t.accent;
1172
- const placeholder = flags.blocked
1173
- ? "blocked · upstream stage owns the prompt"
1174
- : flags.settled || !this.handle
1175
- ? "read-only · stage has no live handle"
1176
- : flags.paused
1177
- ? "type to resume, or Ctrl+P to release without input…"
1178
- : flags.streaming
1179
- ? "type to steer the current turn… (queues with ↵)"
1180
- : "type a message…";
1181
-
864
+ const available = Math.max(1, width - 3);
1182
865
  const value = this.inputBuffer
1183
- ? paint(truncateToWidth(this.inputBuffer, Math.max(8, width - 6)), t.text) + paint("▌", t.text)
1184
- : paint(placeholder, t.dim, { italic: true });
1185
-
1186
- const tag = flags.streaming
1187
- ? paint("streaming", t.accent, { bold: true })
1188
- : flags.paused
1189
- ? paint("paused", t.warning, { bold: true })
1190
- : flags.settled
1191
- ? paint("settled", t.success, { bold: true })
1192
- : paint("idle", t.dim);
1193
- const tagWidth = visibleWidth(stripAnsi(tag));
1194
- const left = " " + paint("❯", glyphHex, { bold: true }) + " " + value;
1195
- const valueWidth = visibleWidth(this.inputBuffer || placeholder);
1196
- const leftWidth = 1 + 1 + 2 + valueWidth + (this.inputBuffer ? 1 : 0);
1197
- const gap = Math.max(1, width - leftWidth - tagWidth - 2);
1198
- const body = left + " ".repeat(gap) + tag + " ";
1199
- return flags.omitTopRule ? [body, rule] : [rule, body, rule];
866
+ ? paint(truncateToWidth(this.inputBuffer, available), t.text) + cursorBlock()
867
+ : disabled
868
+ ? ""
869
+ : cursorBlock();
870
+
871
+ const left = paint("❯", glyphHex, { bold: true }) + " " + value;
872
+ const gap = Math.max(0, width - visibleWidth(stripAnsi(left)));
873
+ const body = left + " ".repeat(gap);
874
+ return [rule, body, rule];
875
+ }
876
+
877
+ private _editorRuleColor(disabled: boolean): string {
878
+ if (disabled) return this.theme.borderDim;
879
+ const level = this.handle?.agentSession?.state.thinkingLevel ?? "off";
880
+ switch (level) {
881
+ case "minimal":
882
+ return this.theme.borderDim;
883
+ case "low":
884
+ return this.theme.info;
885
+ case "medium":
886
+ return this.theme.accent;
887
+ case "high":
888
+ return this.theme.mauve;
889
+ case "xhigh":
890
+ return this.theme.error;
891
+ case "off":
892
+ default:
893
+ return this.theme.border;
894
+ }
1200
895
  }
1201
896
 
1202
897
  // -------------------------------------------------------------------------
1203
- // Footertwo dim lines mirroring Pi's FooterComponent
898
+ // Working, usage + footer mirrors the main chat composer stack
1204
899
  // -------------------------------------------------------------------------
1205
900
 
1206
- private _renderFooter(
901
+ private _renderWorkingStatus(
1207
902
  width: number,
1208
903
  stage: StageSnapshot | undefined,
1209
- flags: { paused: boolean; streaming: boolean; settled: boolean },
904
+ flags: { streaming: boolean },
1210
905
  ): string[] {
906
+ if (!flags.streaming) return [];
1211
907
  const t = this.theme;
1212
- const sessionId = this.handle?.sessionId ?? stage?.sessionId;
1213
- const messages = this.handle?.messages.length ?? this.transcript.length;
1214
- const dur = stageDurationText(stage) ?? "";
1215
-
1216
- // Top line — left: workflow / stage tag; right: session id
1217
- const lTop = paint(`pi-workflows/${this.workflowName}/${stage?.name ?? "stage"}`, t.dim);
1218
- const rTop = sessionId
1219
- ? paint("session ", t.dim) + paint(shortenId(sessionId), t.textMuted)
1220
- : paint("session not yet realised", t.dim);
1221
- const top = layoutRow(width, " ", " " + lTop, rTop + " ", t);
1222
-
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
- : "";
1229
- const lBot =
1230
- paint(`◇ ${messages} messages`, t.dim) +
1231
- (dur ? " " + paint(`· ${dur}`, t.dim) : "") +
1232
- paint(history, t.dim);
1233
- const rBot = flags.streaming
1234
- ? paint("streaming · live", t.accent)
1235
- : flags.paused
1236
- ? paint("paused · ready to resume", t.warning)
1237
- : flags.settled && stage?.status === "completed"
1238
- ? paint("completed · session persisted", t.success)
1239
- : flags.settled && stage?.status === "failed"
1240
- ? paint("failed · see error", t.error)
1241
- : paint(this.statusMessage || "idle · awaiting input", t.dim);
1242
- const bot = layoutRow(width, " ", " " + lBot, rBot + " ", t);
1243
- return [top, bot];
908
+ const dur = stageDurationText(stage);
909
+ const message = this.workingMessage ?? `Working${dur ? " · " + dur : ""}`;
910
+ return new WorkingStatusComponent({
911
+ spinner: spinnerFrame(),
912
+ message,
913
+ spinnerColor: (text) => paint(text, t.accent, { bold: true }),
914
+ messageColor: (text) => paint(text, t.textMuted),
915
+ }).render(width);
1244
916
  }
1245
917
 
1246
- // -------------------------------------------------------------------------
1247
- // Hints dashed rule + key bindings
1248
- // -------------------------------------------------------------------------
1249
-
1250
- private _renderHints(
1251
- width: number,
1252
- flags: { paused: boolean; streaming: boolean; settled: boolean },
1253
- ): string[] {
1254
- const t = this.theme;
1255
- const dash = hexToAnsi(t.borderDim) + "╌".repeat(width) + RESET;
1256
- const hints = this._hintSet(flags);
1257
- const sep = paint(" · ", t.dim);
1258
- const rendered = hints
1259
- .map(({ key, label, emphasis }) =>
1260
- paint(key, t.text, { bold: true }) +
1261
- " " +
1262
- paint(label, emphasis ? t.textMuted : t.dim, emphasis ? { bold: true } : {}),
1263
- )
1264
- .join(sep);
1265
- const tagPlain = `pi-workflows/${this.workflowName}`;
1266
- const renderedW = visibleWidth(stripAnsi(rendered));
1267
- const tagW = visibleWidth(tagPlain);
1268
- // Right-side tag is "nice to have". When the hint line + tag overflows
1269
- // the chrome, drop the tag — the hints are the load-bearing affordance.
1270
- if (renderedW + tagW + 3 > width) {
1271
- const gap = Math.max(1, width - renderedW - 1);
1272
- return [dash, " " + rendered + " ".repeat(gap)];
1273
- }
1274
- const tag = paint(tagPlain, t.dim);
1275
- const gap = Math.max(1, width - renderedW - tagW - 2);
1276
- return [dash, " " + rendered + " ".repeat(gap) + tag + " "];
918
+ private _renderUsage(width: number): string[] {
919
+ const agentSession = this.handle?.agentSession;
920
+ if (!agentSession) return [];
921
+ return new UsageMeterComponent(agentSession).render(width);
1277
922
  }
1278
923
 
1279
- private _hintSet(flags: {
1280
- paused: boolean;
1281
- streaming: boolean;
1282
- settled: boolean;
1283
- }): Array<{ key: string; label: string; emphasis?: boolean }> {
1284
- const historyHint = { key: "PgUp/PgDn", label: "history" };
1285
- if (flags.settled) {
1286
- return [
1287
- historyHint,
1288
- { key: "Ctrl+D", label: "back to graph", emphasis: true },
1289
- { key: "Esc", label: "close" },
1290
- ];
1291
- }
1292
- if (flags.paused) {
1293
- return [
1294
- { key: "↵", label: "resume with message", emphasis: true },
1295
- { key: "Ctrl+P", label: "resume empty" },
1296
- historyHint,
1297
- { key: "Ctrl+D", label: "back" },
1298
- { key: "Esc", label: "close" },
1299
- ];
1300
- }
1301
- if (flags.streaming) {
1302
- return [
1303
- { key: "↵", label: "steer", emphasis: true },
1304
- { key: "Ctrl+F", label: "follow-up", emphasis: true },
1305
- { key: "Ctrl+P", label: "pause" },
1306
- historyHint,
1307
- { key: "Ctrl+D", label: "back" },
1308
- { key: "Esc", label: "interrupt" },
1309
- ];
924
+ private _renderFooter(width: number): string[] {
925
+ const agentSession = this.handle?.agentSession;
926
+ if (agentSession && this.footerData) {
927
+ return new FooterComponent(agentSession, this.footerData).render(width);
1310
928
  }
1311
- return [
1312
- { key: "↵", label: "send", emphasis: true },
1313
- { key: "Ctrl+F", label: "follow-up" },
1314
- { key: "Ctrl+P", label: "pause" },
1315
- historyHint,
1316
- { key: "Ctrl+D", label: "back" },
1317
- { key: "Esc", label: "close" },
1318
- ];
929
+ return [];
1319
930
  }
1320
931
 
1321
932
  // -------------------------------------------------------------------------
1322
933
  // Small helpers
1323
934
  // -------------------------------------------------------------------------
1324
935
 
1325
- private _completedMeta(stage: StageSnapshot | undefined): string {
1326
- const dur = stageDurationText(stage);
1327
- const parts: string[] = ["stage settled"];
1328
- if (dur) parts.push(dur);
1329
- if (stage?.sessionFile) parts.push(`session ${shortenFile(stage.sessionFile)}`);
1330
- return parts.join(" · ");
1331
- }
1332
-
1333
936
  private _blank(width: number): string {
1334
937
  return " ".repeat(width);
1335
938
  }
@@ -1350,20 +953,19 @@ export class StageChatView implements Component {
1350
953
  this.onDetach();
1351
954
  return true;
1352
955
  }
1353
- if (data === "\x1b") {
1354
- if (this._isStreaming() && !this._isBlocked()) {
956
+ if (matchesKey(data, "escape")) {
957
+ if (this._canPause()) {
1355
958
  void this._pause();
1356
959
  } else {
1357
960
  this.onClose();
1358
961
  }
1359
962
  return true;
1360
963
  }
1361
- const blocked = this._isBlocked();
1362
- if (data === "\x10") {
1363
- if (blocked) return true;
1364
- void this._pause();
964
+ if (data === "\x03") {
965
+ this.onClose();
1365
966
  return true;
1366
967
  }
968
+ const blocked = this._isBlocked();
1367
969
  if (data === "\x06") {
1368
970
  if (blocked) return true;
1369
971
  void this._submit("followUp");
@@ -1392,6 +994,13 @@ export class StageChatView implements Component {
1392
994
  return false;
1393
995
  }
1394
996
 
997
+ private _canPause(): boolean {
998
+ if (!this.handle || this.localPaused || this._isBlocked()) return false;
999
+ const stage = this._currentStage();
1000
+ if (stage?.status === "paused") return false;
1001
+ return this._isStreaming();
1002
+ }
1003
+
1395
1004
  private async _pause(): Promise<void> {
1396
1005
  if (!this.handle) {
1397
1006
  this.statusMessage = "no live handle on this stage";
@@ -1404,7 +1013,7 @@ export class StageChatView implements Component {
1404
1013
  try {
1405
1014
  await this.handle.pause();
1406
1015
  this.sdkBusy = false;
1407
- this.statusMessage = "paused";
1016
+ this.statusMessage = "";
1408
1017
  } catch (err) {
1409
1018
  this.statusMessage = `pause failed: ${err instanceof Error ? err.message : String(err)}`;
1410
1019
  this.localPaused = false;
@@ -1414,7 +1023,35 @@ export class StageChatView implements Component {
1414
1023
  }
1415
1024
  }
1416
1025
 
1417
- private async _submit(mode: "auto" | "followUp", submittedText?: string): Promise<void> {
1026
+ private async _resume(message?: string): Promise<void> {
1027
+ if (!this.handle) {
1028
+ this.statusMessage = "no live handle on this stage";
1029
+ this.requestRender?.();
1030
+ return;
1031
+ }
1032
+ this.localPaused = true;
1033
+ this.sdkBusy = true;
1034
+ this.statusMessage = "resuming…";
1035
+ this._syncAnimationTick();
1036
+ this.requestRender?.();
1037
+ try {
1038
+ await this.handle.resume(message);
1039
+ this.localPaused = false;
1040
+ this.sdkBusy = false;
1041
+ this.statusMessage = "";
1042
+ } catch (err) {
1043
+ this.sdkBusy = false;
1044
+ this.statusMessage = `resume failed: ${err instanceof Error ? err.message : String(err)}`;
1045
+ } finally {
1046
+ this._syncAnimationTick();
1047
+ this.requestRender?.();
1048
+ }
1049
+ }
1050
+
1051
+ private async _submit(
1052
+ mode: "auto" | "followUp",
1053
+ submittedText?: string,
1054
+ ): Promise<void> {
1418
1055
  const text = (submittedText ?? this.inputBuffer).trim();
1419
1056
  if (!text) return;
1420
1057
  this.inputBuffer = "";
@@ -1423,6 +1060,7 @@ export class StageChatView implements Component {
1423
1060
  this.statusMessage = "no live handle on this stage";
1424
1061
  this.transcript.push({
1425
1062
  role: "system",
1063
+ kind: "system",
1426
1064
  text: "(no live handle — message dropped)",
1427
1065
  });
1428
1066
  this.requestRender?.();
@@ -1433,13 +1071,8 @@ export class StageChatView implements Component {
1433
1071
  this.optimisticUserSignatures.add(userMessageSignature(text));
1434
1072
  this.requestRender?.();
1435
1073
  try {
1436
- if (this.localPaused) {
1437
- this.sdkBusy = true;
1438
- this._syncAnimationTick();
1439
- await this.handle.resume(text);
1440
- this.localPaused = false;
1441
- this.statusMessage = "resumed";
1442
- this.requestRender?.();
1074
+ if (this._isPaused()) {
1075
+ await this._resume(text);
1443
1076
  return;
1444
1077
  }
1445
1078
  if (mode === "followUp") {
@@ -1475,6 +1108,10 @@ export class StageChatView implements Component {
1475
1108
  clearInterval(this.animationTimer);
1476
1109
  this.animationTimer = undefined;
1477
1110
  }
1111
+ if (this.renderThrottleTimer) {
1112
+ clearTimeout(this.renderThrottleTimer);
1113
+ this.renderThrottleTimer = undefined;
1114
+ }
1478
1115
  this.editor = undefined;
1479
1116
  }
1480
1117
 
@@ -1482,22 +1119,8 @@ export class StageChatView implements Component {
1482
1119
  get _inputBuffer(): string {
1483
1120
  return this.inputBuffer;
1484
1121
  }
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
- >;
1122
+ get _transcript(): ReadonlyArray<TranscriptDebugEntry> {
1123
+ return this.transcript.flatMap((entry) => transcriptDebugEntries(entry));
1501
1124
  }
1502
1125
  get _statusMessage(): string {
1503
1126
  return this.statusMessage;
@@ -1520,29 +1143,41 @@ export class StageChatView implements Component {
1520
1143
  // Module-private helpers
1521
1144
  // ---------------------------------------------------------------------------
1522
1145
 
1523
- type AssistantComponentMessage = Extract<ChatMessageEntry, { kind: "assistant" }>["message"];
1524
-
1525
- function transcriptDebugEntries(entry: TranscriptEntry): Array<TranscriptEntry & {
1146
+ interface TranscriptDebugEntry {
1147
+ readonly role: string;
1526
1148
  readonly text: string;
1527
1149
  readonly toolCallId: string;
1528
1150
  readonly state: string;
1529
1151
  readonly output: string;
1530
- }> {
1152
+ }
1153
+
1154
+ function transcriptDebugEntries(entry: TranscriptEntry): TranscriptDebugEntry[] {
1531
1155
  if (isChatMessageEntry(entry) && entry.kind === "assistant") {
1532
- const entries: Array<TranscriptEntry & { text: string; toolCallId: string; state: string; output: string }> = [];
1156
+ const entries: TranscriptDebugEntry[] = [];
1533
1157
  const thinking = extractThinkingText(entry.message.content);
1534
1158
  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: "" });
1159
+ if (thinking)
1160
+ entries.push({
1161
+ role: "thinking",
1162
+ text: thinking,
1163
+ toolCallId: "",
1164
+ state: "",
1165
+ output: "",
1166
+ });
1167
+ if (text || entries.length === 0)
1168
+ entries.push({ ...entry, text, toolCallId: "", state: "", output: "" });
1537
1169
  return entries;
1538
1170
  }
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 }];
1171
+ return [
1172
+ {
1173
+ ...entry,
1174
+ role: entry.role,
1175
+ text: transcriptDebugText(entry),
1176
+ toolCallId: transcriptDebugToolCallId(entry),
1177
+ state: transcriptDebugToolState(entry),
1178
+ output: transcriptDebugToolOutput(entry),
1179
+ },
1180
+ ];
1546
1181
  }
1547
1182
 
1548
1183
  function transcriptDebugText(entry: TranscriptEntry): string {
@@ -1571,8 +1206,10 @@ function transcriptDebugText(entry: TranscriptEntry): string {
1571
1206
  }
1572
1207
 
1573
1208
  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;
1209
+ if (isChatMessageEntry(entry) && entry.kind === "tool")
1210
+ return entry.toolCallId;
1211
+ if ("toolCallId" in entry && typeof entry.toolCallId === "string")
1212
+ return entry.toolCallId;
1576
1213
  return "";
1577
1214
  }
1578
1215
 
@@ -1586,64 +1223,63 @@ function transcriptDebugToolState(entry: TranscriptEntry): string {
1586
1223
  }
1587
1224
 
1588
1225
  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;
1226
+ if (isChatMessageEntry(entry) && entry.kind === "tool")
1227
+ return entry.result ? extractToolResultText(entry.result) : "";
1228
+ if ("output" in entry && typeof entry.output === "string")
1229
+ return entry.output;
1591
1230
  return "";
1592
1231
  }
1593
1232
 
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";
1233
+ function setEditorPlaceholder(
1234
+ editor: EditorComponent,
1235
+ placeholder: string | undefined,
1236
+ ): void {
1237
+ const candidate = editor as EditorComponent & {
1238
+ setPlaceholder?: (value: string | undefined) => void;
1239
+ };
1240
+ candidate.setPlaceholder?.(placeholder);
1605
1241
  }
1606
1242
 
1607
- function isLocalToolEntry(entry: TranscriptEntry | undefined): entry is ToolEntry {
1608
- return entry?.role === "tool" && !("kind" in entry);
1243
+ function cursorBlock(): string {
1244
+ return "\x1b[7m \x1b[0m";
1609
1245
  }
1610
1246
 
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;
1247
+ function setEditorBorderColor(editor: EditorComponent, hex: string): void {
1248
+ const candidate = editor as EditorComponent & {
1249
+ borderColor?: (text: string) => string;
1250
+ };
1251
+ if (candidate.borderColor !== undefined) {
1252
+ candidate.borderColor = (text: string) => hexToAnsi(hex) + text + RESET;
1253
+ }
1616
1254
  }
1617
1255
 
1618
- function assistantMessageForText(text: string): AssistantComponentMessage {
1619
- return {
1620
- role: "assistant",
1621
- content: [{ type: "text", text }],
1622
- stopReason: "stop",
1623
- } as AssistantComponentMessage;
1256
+ function setEditorFocused(editor: EditorComponent, focused: boolean): void {
1257
+ const candidate = editor as EditorComponent & Partial<Focusable>;
1258
+ if ("focused" in candidate) candidate.focused = focused;
1624
1259
  }
1625
1260
 
1626
- function assistantMessageForThinking(text: string): AssistantComponentMessage {
1627
- return {
1628
- role: "assistant",
1629
- content: [{ type: "thinking", thinking: text }],
1630
- stopReason: "stop",
1631
- } as AssistantComponentMessage;
1261
+ function isSharedLiveChatEvent(type: string): boolean {
1262
+ return (
1263
+ type === "message_start" ||
1264
+ type === "message_update" ||
1265
+ type === "message_end" ||
1266
+ type === "tool_execution_start" ||
1267
+ type === "tool_execution_update" ||
1268
+ type === "tool_execution_end"
1269
+ );
1632
1270
  }
1633
1271
 
1634
- function toolArgsForRender(entry: ToolEntry): Record<string, unknown> {
1635
- if (!entry.args) return {};
1636
- if (entry.name === "bash") {
1637
- return { command: entry.args.replace(/^command=/, "") };
1638
- }
1639
- return { input: entry.args };
1272
+ function isChatMessageEntry(entry: TranscriptEntry): entry is ChatMessageEntry {
1273
+ return "kind" in entry && entry.role !== "notice";
1640
1274
  }
1641
1275
 
1642
- function isMessageLike(message: unknown): message is { role?: unknown; content?: unknown; stopReason?: unknown; errorMessage?: unknown } {
1276
+ function isMessageLike(message: unknown): message is { role?: unknown; content?: unknown } {
1643
1277
  return message !== null && typeof message === "object" && "role" in message;
1644
1278
  }
1645
1279
 
1646
- function isUserMessageLike(message: unknown): message is { role: "user"; content?: unknown } {
1280
+ function isUserMessageLike(
1281
+ message: unknown,
1282
+ ): message is { role: "user"; content?: unknown } {
1647
1283
  return isMessageLike(message) && message.role === "user";
1648
1284
  }
1649
1285
 
@@ -1651,136 +1287,115 @@ function userMessageSignature(text: string): string {
1651
1287
  return text.trim();
1652
1288
  }
1653
1289
 
1654
- interface AssistantProjection {
1655
- text: string;
1656
- thinking: string;
1657
- toolCalls: Array<{ toolCallId?: string; name: string; args?: string; state: "pending" }>;
1290
+ function assistantToolCallEvent(event: AgentSessionEvent): {
1291
+ type: "tool_execution_start";
1292
+ toolCallId: string;
1293
+ toolName: string;
1294
+ args: unknown;
1295
+ } | undefined {
1296
+ const assistantEvent = (event as {
1297
+ assistantMessageEvent?: {
1298
+ type?: unknown;
1299
+ contentIndex?: unknown;
1300
+ partial?: unknown;
1301
+ toolCall?: unknown;
1302
+ };
1303
+ }).assistantMessageEvent;
1304
+ const streamType = String(assistantEvent?.type ?? "");
1305
+ if (!streamType.startsWith("toolcall_")) return undefined;
1306
+
1307
+ const explicit = toolCallPayload(assistantEvent?.toolCall);
1308
+ if (explicit) return explicit;
1309
+
1310
+ const contentIndex = typeof assistantEvent?.contentIndex === "number" ? assistantEvent.contentIndex : undefined;
1311
+ if (contentIndex === undefined) return undefined;
1312
+ const partial = assistantEvent?.partial;
1313
+ if (!isMessageLike(partial) || partial.role !== "assistant") return undefined;
1314
+ const content = partial.content;
1315
+ if (!Array.isArray(content)) return undefined;
1316
+ return toolCallPayload(content[contentIndex]);
1658
1317
  }
1659
1318
 
1660
- function assistantContentHasRenderablePayload(content: unknown): boolean {
1661
- if (typeof content === "string") return content.length > 0;
1662
- if (!Array.isArray(content)) return false;
1663
- return content.some((item) => {
1664
- if (typeof item === "string") return item.length > 0;
1665
- if (item == null || typeof item !== "object") return false;
1666
- const obj = item as { type?: unknown; text?: unknown; thinking?: unknown };
1667
- return (obj.type === "text" && typeof obj.text === "string" && obj.text.length > 0) ||
1668
- (obj.type === "thinking" && typeof obj.thinking === "string" && obj.thinking.length > 0) ||
1669
- obj.type === "toolCall";
1670
- });
1319
+ function toolCallPayload(value: unknown): {
1320
+ type: "tool_execution_start";
1321
+ toolCallId: string;
1322
+ toolName: string;
1323
+ args: unknown;
1324
+ } | undefined {
1325
+ if (value === null || typeof value !== "object") return undefined;
1326
+ const candidate = value as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown };
1327
+ if (candidate.type !== "toolCall") return undefined;
1328
+ if (typeof candidate.id !== "string" || typeof candidate.name !== "string") return undefined;
1329
+ return {
1330
+ type: "tool_execution_start",
1331
+ toolCallId: candidate.id,
1332
+ toolName: candidate.name,
1333
+ args: candidate.arguments ?? {},
1334
+ };
1671
1335
  }
1672
1336
 
1673
- function projectAssistantContent(content: unknown): AssistantProjection {
1674
- const projection: AssistantProjection = { text: "", thinking: "", toolCalls: [] };
1675
- if (!Array.isArray(content)) {
1676
- projection.text = typeof content === "string" ? content : "";
1677
- return projection;
1678
- }
1679
- const textParts: string[] = [];
1680
- const thinkingParts: string[] = [];
1681
- for (const item of content) {
1682
- if (item == null) continue;
1683
- if (typeof item === "string") {
1684
- textParts.push(item);
1685
- continue;
1686
- }
1687
- if (typeof item !== "object") continue;
1688
- const obj = item as {
1689
- type?: unknown;
1690
- text?: unknown;
1691
- thinking?: unknown;
1692
- id?: unknown;
1693
- name?: unknown;
1694
- arguments?: unknown;
1695
- args?: unknown;
1696
- };
1697
- if (obj.type === "text" && typeof obj.text === "string") {
1698
- textParts.push(obj.text);
1699
- continue;
1700
- }
1701
- if (obj.type === "thinking" && typeof obj.thinking === "string") {
1702
- thinkingParts.push(obj.thinking);
1703
- continue;
1704
- }
1705
- if (obj.type === "toolCall") {
1706
- const name = typeof obj.name === "string" ? obj.name : "tool";
1707
- const toolCallId = typeof obj.id === "string" ? obj.id : undefined;
1708
- const args = summariseArgs(obj.arguments ?? obj.args);
1709
- projection.toolCalls.push({ toolCallId, name, args, state: "pending" });
1710
- }
1711
- }
1712
- projection.text = textParts.join("");
1713
- projection.thinking = thinkingParts.join("\n\n");
1714
- return projection;
1337
+ function legacyToolStartEvent(event: AgentSessionEvent): {
1338
+ type: "tool_execution_start";
1339
+ toolCallId: string;
1340
+ toolName: string;
1341
+ args: unknown;
1342
+ } {
1343
+ const payload = event as { toolCallId?: unknown; name?: unknown; input?: unknown; args?: unknown };
1344
+ const toolName = typeof payload.name === "string" ? payload.name : "tool";
1345
+ const toolCallId =
1346
+ typeof payload.toolCallId === "string" ? payload.toolCallId : `live-${toolName}`;
1347
+ return {
1348
+ type: "tool_execution_start",
1349
+ toolCallId,
1350
+ toolName,
1351
+ args: payload.input ?? payload.args ?? {},
1352
+ };
1715
1353
  }
1716
1354
 
1717
- function shallowToolEntryEqual(a: ToolEntry, b: ToolEntry): boolean {
1718
- return a.role === b.role &&
1719
- a.text === b.text &&
1720
- a.name === b.name &&
1721
- a.toolCallId === b.toolCallId &&
1722
- a.args === b.args &&
1723
- a.output === b.output &&
1724
- a.state === b.state;
1355
+ function legacyToolResultEvent(event: AgentSessionEvent): {
1356
+ type: "tool_execution_end";
1357
+ toolCallId: string;
1358
+ toolName: string;
1359
+ result: unknown;
1360
+ isError: boolean;
1361
+ } {
1362
+ const payload = event as {
1363
+ toolCallId?: unknown;
1364
+ name?: unknown;
1365
+ output?: unknown;
1366
+ isError?: unknown;
1367
+ };
1368
+ const toolName = typeof payload.name === "string" ? payload.name : "tool";
1369
+ const toolCallId =
1370
+ typeof payload.toolCallId === "string" ? payload.toolCallId : `live-${toolName}`;
1371
+ const output = payload.output;
1372
+ return {
1373
+ type: "tool_execution_end",
1374
+ toolCallId,
1375
+ toolName,
1376
+ result:
1377
+ output !== null && typeof output === "object" && "content" in output
1378
+ ? output
1379
+ : { content: typeof output === "string" ? [{ type: "text", text: output }] : [] },
1380
+ isError: payload.isError === true,
1381
+ };
1725
1382
  }
1726
1383
 
1727
- function transcriptEntryFromSnapshotMessage(
1728
- message: AgentSnapshotMessage,
1729
- ): TranscriptEntry | undefined {
1730
- switch (message.role) {
1731
- case "user": {
1732
- const text = extractMessageText(message.content);
1733
- return text ? { role: "user", text } : undefined;
1734
- }
1735
- case "assistant": {
1736
- const text = extractMessageText(message.content);
1737
- return text ? { role: "assistant", text } : undefined;
1738
- }
1739
- case "toolResult": {
1740
- const output = extractMessageText(message.content);
1741
- const summary = output ? truncateToWidth(output.replace(/\s+/g, " "), 80) : "";
1742
- return {
1743
- role: "tool",
1744
- name: message.toolName,
1745
- output,
1746
- state: message.isError ? "error" : "success",
1747
- text: summary ? `← ${message.toolName} ${summary}` : `← ${message.toolName}`,
1748
- };
1749
- }
1750
- case "bashExecution": {
1751
- const state =
1752
- message.cancelled || (message.exitCode !== undefined && message.exitCode !== 0)
1753
- ? "error"
1754
- : "success";
1755
- const summary = message.output ? truncateToWidth(message.output.replace(/\s+/g, " "), 80) : "";
1756
- return {
1757
- role: "tool",
1758
- name: "bash",
1759
- args: truncateToWidth(message.command.replace(/\s+/g, " "), 60),
1760
- output: message.output,
1761
- state,
1762
- text: summary ? `← bash ${summary}` : `→ bash ${message.command}`,
1763
- };
1764
- }
1765
- case "custom": {
1766
- if (!message.display) return undefined;
1767
- const text = extractMessageText(message.content);
1768
- return text ? { role: "system", text } : undefined;
1769
- }
1770
- case "branchSummary": {
1771
- const text = `Branch summary: ${message.summary}`;
1772
- return { role: "system", text };
1773
- }
1774
- case "compactionSummary": {
1775
- const text = `Compaction summary: ${message.summary}`;
1776
- return { role: "system", text };
1777
- }
1778
- default:
1779
- // The SDK message union is extensible. Snapshot unknown roles must be
1780
- // skipped here instead of being cast into `TranscriptEntry`; `_renderBody`
1781
- // only flattens the closed set of components returned by `_renderEntry`.
1782
- return undefined;
1783
- }
1384
+ function legacyThinkingEvent(event: AgentSessionEvent): {
1385
+ type: "message_update";
1386
+ assistantMessageEvent: { type: "thinking_delta"; delta: string };
1387
+ message: { role: "assistant"; content: [] };
1388
+ } {
1389
+ const delta = String(
1390
+ (event as { delta?: unknown }).delta ??
1391
+ (event as { text?: unknown }).text ??
1392
+ "",
1393
+ );
1394
+ return {
1395
+ type: "message_update",
1396
+ assistantMessageEvent: { type: "thinking_delta", delta },
1397
+ message: { role: "assistant", content: [] },
1398
+ };
1784
1399
  }
1785
1400
 
1786
1401
  function extractThinkingText(content: unknown): string {
@@ -1789,7 +1404,11 @@ function extractThinkingText(content: unknown): string {
1789
1404
  for (const item of content) {
1790
1405
  if (item == null || typeof item !== "object") continue;
1791
1406
  const thinking = (item as { type?: unknown; thinking?: unknown }).thinking;
1792
- if ((item as { type?: unknown }).type === "thinking" && typeof thinking === "string") parts.push(thinking);
1407
+ if (
1408
+ (item as { type?: unknown }).type === "thinking" &&
1409
+ typeof thinking === "string"
1410
+ )
1411
+ parts.push(thinking);
1793
1412
  }
1794
1413
  return parts.join("\n\n");
1795
1414
  }
@@ -1806,7 +1425,8 @@ function extractMessageText(content: unknown): string {
1806
1425
  }
1807
1426
  const obj = item as { type?: unknown; text?: unknown };
1808
1427
  if (typeof obj.text === "string") parts.push(obj.text);
1809
- else if (obj.type === "text" && typeof obj.text === "string") parts.push(obj.text);
1428
+ else if (obj.type === "text" && typeof obj.text === "string")
1429
+ parts.push(obj.text);
1810
1430
  }
1811
1431
  return parts.join("");
1812
1432
  }
@@ -1818,43 +1438,27 @@ function extractToolResultText(result: unknown): string {
1818
1438
  return extractMessageText(content);
1819
1439
  }
1820
1440
 
1821
- function findToolEntryIndex(
1822
- entries: readonly TranscriptEntry[],
1823
- toolCallId: string | undefined,
1824
- name: string,
1825
- ): number {
1826
- if (toolCallId !== undefined) {
1827
- for (let i = entries.length - 1; i >= 0; i--) {
1828
- const entry = entries[i];
1829
- if (isLocalToolEntry(entry) && entry.toolCallId === toolCallId) return i;
1830
- }
1831
- }
1832
- for (let i = entries.length - 1; i >= 0; i--) {
1833
- const entry = entries[i];
1834
- if (isLocalToolEntry(entry) && entry.name === name && entry.state === "pending") return i;
1835
- }
1836
- return -1;
1837
- }
1838
-
1839
- function summariseArgs(input: unknown): string {
1840
- if (input == null) return "";
1841
- if (typeof input === "string") return truncateToWidth(input.replace(/\s+/g, " "), 60);
1842
- if (typeof input !== "object") return String(input);
1843
- const obj = input as Record<string, unknown>;
1844
- const keys = Object.keys(obj);
1845
- if (keys.length === 0) return "";
1846
- const head = keys[0]!;
1847
- const value = obj[head];
1848
- const summary = typeof value === "string" ? value : JSON.stringify(value);
1849
- const formatted = `${head}=${summary}`;
1850
- return truncateToWidth(formatted.replace(/\s+/g, " "), 60);
1851
- }
1852
-
1853
1441
  function noticeSummary(n: StageNotice): string {
1854
1442
  const base = `~ ${n.kind} → ${n.to}`;
1855
1443
  return n.from ? `${base} (was ${n.from})` : base;
1856
1444
  }
1857
1445
 
1446
+ function tailStreamingText(text: string): string {
1447
+ if (
1448
+ text.length <= STREAMING_TEXT_TAIL_CHARS &&
1449
+ text.split("\n").length <= STREAMING_TEXT_TAIL_LINES
1450
+ ) {
1451
+ return text;
1452
+ }
1453
+ const byChars = text.slice(-STREAMING_TEXT_TAIL_CHARS);
1454
+ const lines = byChars.split("\n");
1455
+ const tail =
1456
+ lines.length > STREAMING_TEXT_TAIL_LINES
1457
+ ? lines.slice(-STREAMING_TEXT_TAIL_LINES).join("\n")
1458
+ : byChars;
1459
+ return `[earlier streaming output hidden while attached]\n\n${tail.trimStart()}`;
1460
+ }
1461
+
1858
1462
  function stageDurationText(stage: StageSnapshot | undefined): string {
1859
1463
  if (!stage?.startedAt) return "";
1860
1464
  const end = stage.endedAt ?? Date.now();
@@ -1878,15 +1482,6 @@ function shortenId(id: string): string {
1878
1482
  return id.length > 10 ? id.slice(0, 8) : id;
1879
1483
  }
1880
1484
 
1881
- function shortenFile(path: string): string {
1882
- if (path.length <= 36) return path;
1883
- // Keep the basename and an ellipsis prefix so the user can still recognise
1884
- // which session file we're pointing at.
1885
- const slash = path.lastIndexOf("/");
1886
- if (slash < 0) return "…" + path.slice(-35);
1887
- return "…" + path.slice(Math.max(slash - 12, 0));
1888
- }
1889
-
1890
1485
  function spinnerFrame(): string {
1891
1486
  const idx = Math.floor(Date.now() / 80) % SPINNER_FRAMES.length;
1892
1487
  return SPINNER_FRAMES[idx]!;
@@ -1898,7 +1493,8 @@ function bgFn(hex: string): (text: string) => string {
1898
1493
  }
1899
1494
 
1900
1495
  function editorThemeFromGraphTheme(t: GraphTheme): EditorTheme {
1901
- const selected = (text: string): string => hexBg(t.backgroundPanel) + hexToAnsi(t.text) + text + RESET;
1496
+ const selected = (text: string): string =>
1497
+ hexBg(t.backgroundPanel) + hexToAnsi(t.text) + text + RESET;
1902
1498
  const normal = (text: string): string => hexToAnsi(t.text) + text + RESET;
1903
1499
  return {
1904
1500
  borderColor: (text: string) => hexToAnsi(t.border) + text + RESET,
@@ -1948,31 +1544,6 @@ function stripAnsi(s: string): string {
1948
1544
  return s.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
1949
1545
  }
1950
1546
 
1951
- function centred(content: string, width: number): string {
1952
- const w = visibleWidth(stripAnsi(content));
1953
- if (w >= width) return content;
1954
- const left = Math.floor((width - w) / 2);
1955
- const right = width - w - left;
1956
- return " ".repeat(left) + content + " ".repeat(right);
1957
- }
1958
-
1959
- /**
1960
- * Compose a two-column row of `${prefix}${left}…${right}` padded to width.
1961
- * Used by the footer to lay out left/right slabs without losing ANSI runs.
1962
- */
1963
- function layoutRow(
1964
- width: number,
1965
- _prefix: string,
1966
- left: string,
1967
- right: string,
1968
- _theme: GraphTheme,
1969
- ): string {
1970
- const lw = visibleWidth(stripAnsi(left));
1971
- const rw = visibleWidth(stripAnsi(right));
1972
- const gap = Math.max(1, width - lw - rw);
1973
- return left + " ".repeat(gap) + right;
1974
- }
1975
-
1976
1547
  /**
1977
1548
  * Approximate a tinted background by mixing the base canvas with a saturated
1978
1549
  * hue at low alpha. Used for status pills and tool-bar tints. Returns a hex