@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
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Visual contract (DESIGN.md):
5
5
  * - No manual ASCII frame. `pi.ui.custom({ overlay: true })` provides the
6
- * popup chrome; this renderer paints content on the canvas (`bg`) with
7
- * full-width chrome rows for the header (top) and hints (bottom).
6
+ * popup chrome; this renderer leaves one unpainted row above and below
7
+ * the panel, then paints content on the canvas (`bg`) with full-width
8
+ * chrome rows for the header (top) and hints (bottom).
8
9
  * - Section labels use the `▎ LABEL` pattern: mauve glyph + `textMuted`
9
10
  * bold caps.
10
11
  * - Hints follow `<key> <label>` separated by ` · ` in `dim`, active key
@@ -16,6 +17,7 @@
16
17
  * - DESIGN.md §4 (Elevation), §5 (Components)
17
18
  */
18
19
  import type { Component } from "@earendil-works/pi-tui";
20
+ import { sliceByColumn } from "@earendil-works/pi-tui/dist/utils.js";
19
21
  import {
20
22
  matchesKey,
21
23
  truncateToWidth,
@@ -118,7 +120,7 @@ const HINT_KEYS: Array<{ key: string; label: string }> = [
118
120
  { key: "↵", label: "attach" },
119
121
  { key: "/", label: "stages" },
120
122
  { key: "ctrl+d", label: "detach" },
121
- { key: "q", label: "quit" },
123
+ { key: "q", label: "kill" },
122
124
  ];
123
125
 
124
126
  /**
@@ -140,6 +142,7 @@ const MODE_PILL_LABEL = "GRAPH";
140
142
  * same number of lines per frame regardless of game state.
141
143
  */
142
144
  const OVERLAY_LINE_COUNT = 32;
145
+ const OVERLAY_VERTICAL_MARGIN_ROWS = 1;
143
146
 
144
147
  /**
145
148
  * Animation tick period. Overlay re-renders fire on this cadence so
@@ -228,6 +231,7 @@ export class GraphView implements Component {
228
231
  this._intervalId = setInterval(() => {
229
232
  this.requestRender?.();
230
233
  }, ANIMATION_TICK_MS);
234
+ (this._intervalId as { unref?: () => void }).unref?.();
231
235
  }
232
236
  }
233
237
 
@@ -341,8 +345,10 @@ export class GraphView implements Component {
341
345
  * Number of rows the overlay frame must paint. Pi-tui anchors the
342
346
  * overlay vertically by counting rendered lines, so to truly fill the
343
347
  * terminal under `maxHeight: "100%"` the component must emit
344
- * approximately `terminal.rows` lines. We clamp to at least
345
- * `OVERLAY_LINE_COUNT` so a tiny terminal or a host that doesn't
348
+ * approximately `terminal.rows` lines. When a host reports fewer
349
+ * than the legacy 32 rows, honour that shorter viewport where the
350
+ * graph chrome can still fit so the bottom status controls are not
351
+ * clipped by pi-tui's overlay max-height slicing. A host that doesn't
346
352
  * surface terminal dimensions keeps the previous stable rectangle.
347
353
  */
348
354
  private _overlayLineCount(): number {
@@ -350,9 +356,10 @@ export class GraphView implements Component {
350
356
  if (typeof reported !== "number" || !Number.isFinite(reported)) {
351
357
  return OVERLAY_LINE_COUNT;
352
358
  }
353
- // Header (3) + statusline (3) is the absolute minimum; anything
354
- // smaller would underflow the body band.
355
- return Math.max(OVERLAY_LINE_COUNT, Math.floor(reported));
359
+ // Header (3) + one body row + statusline (3) is the absolute
360
+ // minimum useful frame. Margins are dropped automatically at this
361
+ // size by `_overlayVerticalMarginRows`.
362
+ return Math.max(7, Math.floor(reported));
356
363
  }
357
364
 
358
365
  /** Rows available for the graph body (between header and statusline). */
@@ -360,6 +367,36 @@ export class GraphView implements Component {
360
367
  return Math.max(1, lineCount - 3 /* header */ - 3 /* statusline */);
361
368
  }
362
369
 
370
+ private _overlayVerticalMarginRows(lineCount = this._overlayLineCount()): number {
371
+ return lineCount >= 9 ? OVERLAY_VERTICAL_MARGIN_ROWS : 0;
372
+ }
373
+
374
+ private _overlayPanelLineCount(): number {
375
+ const lineCount = this._overlayLineCount();
376
+ const margins = this._overlayVerticalMarginRows(lineCount) * 2;
377
+ return Math.max(7, lineCount - margins);
378
+ }
379
+
380
+ private _marginRow(width: number): string {
381
+ return " ".repeat(width);
382
+ }
383
+
384
+ private _withVerticalMargins(panelLines: string[], width: number): string[] {
385
+ const expected = this._overlayLineCount();
386
+ const marginRows = this._overlayVerticalMarginRows(expected);
387
+ const panelTarget = this._overlayPanelLineCount();
388
+ const body = panelLines.slice(0, panelTarget);
389
+ while (body.length < panelTarget) body.push(this._blankRow(width));
390
+ const margins = Array.from({ length: marginRows }, () => this._marginRow(width));
391
+ const lines = [...margins, ...body, ...margins];
392
+ if (lines.length > expected) return lines.slice(0, expected);
393
+ while (lines.length < expected) {
394
+ const insertAt = marginRows > 0 ? Math.max(0, lines.length - marginRows) : lines.length;
395
+ lines.splice(insertAt, 0, this._blankRow(width));
396
+ }
397
+ return lines;
398
+ }
399
+
363
400
  private _renderOverlay(width: number): string[] {
364
401
  const frameWidth = Math.max(40, width);
365
402
  const lines: string[] = [];
@@ -377,14 +414,12 @@ export class GraphView implements Component {
377
414
  // 2. Graph occupies the full body. No section labels, no focused-
378
415
  // stage panel — status colour on each card carries that signal.
379
416
  const graphLines = this._renderGraph(frameWidth);
380
- const bodyTarget = this._overlayBodyRows(this._overlayLineCount());
417
+ const bodyTarget = this._overlayBodyRows(this._overlayPanelLineCount());
381
418
  const visibleGraph = this._visibleGraphLines(
382
419
  graphLines,
383
420
  frameWidth,
384
421
  bodyTarget,
385
422
  );
386
- // Vertically centre short graphs; tall graphs are clipped to the
387
- // scroll window managed by keyboard focus and mouse wheel events.
388
423
  for (let i = 0; i < visibleGraph.topPad; i++)
389
424
  lines.push(this._blankRow(frameWidth));
390
425
  for (const line of visibleGraph.lines) {
@@ -396,17 +431,23 @@ export class GraphView implements Component {
396
431
 
397
432
  // 3. Switcher overlay — floats over the body when open.
398
433
  if (this.switcherOpen) {
399
- const switcherWidth = Math.min(56, frameWidth - 4);
434
+ const bodyStart = 3;
435
+ const bodyEnd = 3 + bodyTarget;
436
+ for (let row = bodyStart; row < bodyEnd; row++) {
437
+ lines[row] = this._blankRow(frameWidth);
438
+ }
439
+ const switcherWidth = Math.min(60, Math.max(40, frameWidth - 8));
400
440
  const switcherLines = renderSwitcher(run.stages, this.switcherState, {
401
441
  width: switcherWidth,
402
442
  theme: this.graphTheme,
403
443
  });
404
- const insertAt = 4; // beneath the header chrome band
444
+ const insertAt = Math.max(bodyStart, bodyStart + Math.min(2, Math.floor((bodyTarget - switcherLines.length) / 3)));
445
+ const switcherLeft = Math.max(2, Math.floor((frameWidth - switcherWidth) / 2));
405
446
  for (let i = 0; i < switcherLines.length; i++) {
406
447
  const lineIdx = insertAt + i;
407
- const overlay = this._padCanvas(switcherLines[i]!, switcherWidth);
448
+ if (lineIdx >= bodyEnd) break;
408
449
  const base = lines[lineIdx] ?? this._blankRow(frameWidth);
409
- const merged = this._overlayInline(base, overlay, 0, frameWidth);
450
+ const merged = this._overlayCard(base, switcherLines[i]!, switcherLeft, frameWidth);
410
451
  if (lineIdx < lines.length) lines[lineIdx] = merged;
411
452
  else lines.push(merged);
412
453
  }
@@ -453,7 +494,7 @@ export class GraphView implements Component {
453
494
  // 5. Three-row statusline pinned to the bottom.
454
495
  lines.push(...this._renderStatusline(frameWidth));
455
496
 
456
- return lines;
497
+ return this._withVerticalMargins(lines, frameWidth);
457
498
  }
458
499
 
459
500
  private _renderEmptyState(width: number): string[] {
@@ -477,7 +518,7 @@ export class GraphView implements Component {
477
518
  `${chromeBg} ${RESET}${mid}${chromeBg}${idleLabel}${filler}${" ".repeat(2)}${RESET}`,
478
519
  `${chromeBg} ${RESET}${bot}${chromeBg}${" ".repeat(6 + fillerVisible)}${" ".repeat(2)}${RESET}`,
479
520
  ];
480
- const bodyTarget = this._overlayBodyRows(this._overlayLineCount());
521
+ const bodyTarget = this._overlayBodyRows(this._overlayPanelLineCount());
481
522
  const body: string[] = [
482
523
  this._canvasRow(` ${muted}No active workflow run.${RESET}`, width),
483
524
  this._canvasRow(
@@ -490,7 +531,7 @@ export class GraphView implements Component {
490
531
  for (const l of body) lines.push(l);
491
532
  while (lines.length < 3 + bodyTarget) lines.push(this._blankRow(width));
492
533
  lines.push(...this._renderStatusline(width));
493
- return lines;
534
+ return this._withVerticalMargins(lines, width);
494
535
  }
495
536
 
496
537
  private _renderGraph(width: number): string[] {
@@ -600,9 +641,8 @@ export class GraphView implements Component {
600
641
  const leftPad = `${bg}${" ".repeat(leftMargin)}${RESET}`;
601
642
  return composed.map((line) => {
602
643
  const full = this._padCanvas(line, fullCanvasWidth);
603
- const cells = this._splitVisible(full);
604
- const sliced = this._sliceVisible(
605
- cells,
644
+ const sliced = this._sliceColumns(
645
+ full,
606
646
  this.graphScrollColOffset,
607
647
  this.graphScrollColOffset + viewportWidth,
608
648
  );
@@ -620,7 +660,7 @@ export class GraphView implements Component {
620
660
  this.pendingEnsureFocusedVisible = false;
621
661
  return {
622
662
  lines: graphLines,
623
- topPad: Math.max(0, Math.floor((bodyRows - graphLines.length) / 2)),
663
+ topPad: Math.min(3, Math.max(0, Math.floor((bodyRows - graphLines.length) / 2))),
624
664
  };
625
665
  }
626
666
 
@@ -696,9 +736,9 @@ export class GraphView implements Component {
696
736
  * Plot a parent → child edge for the vertical orientation. The edge
697
737
  * exits from the parent's bottom-centre, runs through a horizontal
698
738
  * spine half-way down the gap, and re-enters from the child's
699
- * top-centre. Corners at the spine are upgraded to T-junctions when
700
- * a second edge crosses the same cell so fan-out and fan-in render
701
- * cleanly as a single rule with stubs.
739
+ * top-centre. Cells are merged by direction set so fan-out, fan-in,
740
+ * and crossings produce stable orthogonal junctions instead of
741
+ * stacked rounded corners.
702
742
  */
703
743
  private _plotEdge(
704
744
  canvas: GraphCanvas,
@@ -724,112 +764,34 @@ export class GraphView implements Component {
724
764
  Math.min(childEntryRow, Math.floor((parentExitRow + childEntryRow) / 2)),
725
765
  );
726
766
 
727
- // Down stub from parent into the spine row — only when there's
728
- // a non-zero gap, otherwise vline would paint the spineRow corner
729
- // cell before we get to set it.
767
+ // Down stub from parent into the spine row.
730
768
  if (spineRow > parentExitRow) {
731
769
  canvas.vline(parentCol, parentExitRow, spineRow - 1, color);
732
770
  }
733
- // Corner where the parent stub meets the spine. Upgrade to a
734
- // T-down (`┬`) when another edge has already crossed this cell.
735
- this._placeCornerOrTee(
736
- canvas,
737
- spineRow,
738
- parentCol,
739
- childCol > parentCol ? "╰" : "╯",
740
- color,
741
- );
771
+ this._placeJunction(canvas, spineRow, parentCol, ["u", childCol > parentCol ? "r" : "l"], color);
772
+
742
773
  // Horizontal spine segment.
743
774
  const hloCol = Math.min(parentCol, childCol) + 1;
744
775
  const hhiCol = Math.max(parentCol, childCol) - 1;
745
776
  if (hhiCol >= hloCol) {
746
777
  canvas.hline(spineRow, hloCol, hhiCol, color);
747
778
  }
748
- // Corner where the spine drops into the child stub.
749
- this._placeCornerOrTee(
750
- canvas,
751
- spineRow,
752
- childCol,
753
- childCol > parentCol ? "╮" : "╭",
754
- color,
755
- );
756
- // Down stub from spine into child — same zero-gap guard.
779
+ this._placeJunction(canvas, spineRow, childCol, [childCol > parentCol ? "l" : "r", "d"], color);
780
+
781
+ // Down stub from spine into child.
757
782
  if (childEntryRow > spineRow) {
758
783
  canvas.vline(childCol, spineRow + 1, childEntryRow, color);
759
784
  }
760
785
  }
761
786
 
762
- /**
763
- * Place a corner glyph at `(row, col)`. When the target cell already
764
- * holds a glyph from a previous edge, upgrade to the correct
765
- * box-drawing junction so fan-out / fan-in points read as a single
766
- * rule rather than two stacked corners.
767
- *
768
- * Junction table (existing ⊕ new → result):
769
- * | ⊕ ╰ → ├ | ⊕ ╮ → ┤ | ⊕ ╯ → ┤ | ⊕ ╭ → ├
770
- * ─ ⊕ ╭ → ┬ ─ ⊕ ╮ → ┬ ─ ⊕ ╰ → ┴ ─ ⊕ ╯ → ┴
771
- * ╰ ⊕ ╮ → ┴ ╭ ⊕ ╯ → ┴ ╭ ⊕ ╮ → ┬ etc.
772
- *
773
- * The full table is small; we collapse it to: "existing-direction-set
774
- * ∪ new-direction-set → best matching junction".
775
- */
776
- private _placeCornerOrTee(
787
+ private _placeJunction(
777
788
  canvas: GraphCanvas,
778
789
  row: number,
779
790
  col: number,
780
- corner: string,
791
+ newDirs: Array<"u" | "d" | "l" | "r">,
781
792
  color: string,
782
793
  ): void {
783
- const existing = (
784
- canvas as unknown as { rows: Map<number, Map<number, { ch: string }>> }
785
- ).rows
786
- .get(row)
787
- ?.get(col)?.ch;
788
- if (existing == null || existing === corner) {
789
- canvas.setCell(row, col, corner, color);
790
- return;
791
- }
792
- const dirs = (ch: string): Set<"u" | "d" | "l" | "r"> => {
793
- switch (ch) {
794
- case "│":
795
- return new Set(["u", "d"]);
796
- case "─":
797
- return new Set(["l", "r"]);
798
- case "╭":
799
- return new Set(["d", "r"]);
800
- case "╮":
801
- return new Set(["d", "l"]);
802
- case "╰":
803
- return new Set(["u", "r"]);
804
- case "╯":
805
- return new Set(["u", "l"]);
806
- case "├":
807
- return new Set(["u", "d", "r"]);
808
- case "┤":
809
- return new Set(["u", "d", "l"]);
810
- case "┬":
811
- return new Set(["d", "l", "r"]);
812
- case "┴":
813
- return new Set(["u", "l", "r"]);
814
- case "┼":
815
- return new Set(["u", "d", "l", "r"]);
816
- default:
817
- return new Set();
818
- }
819
- };
820
- const merged = new Set<"u" | "d" | "l" | "r">([
821
- ...dirs(existing),
822
- ...dirs(corner),
823
- ]);
824
- const has = (...want: Array<"u" | "d" | "l" | "r">) =>
825
- want.every((d) => merged.has(d));
826
- let glyph = corner;
827
- if (has("u", "d", "l", "r")) glyph = "┼";
828
- else if (has("u", "d", "r")) glyph = "├";
829
- else if (has("u", "d", "l")) glyph = "┤";
830
- else if (has("d", "l", "r")) glyph = "┬";
831
- else if (has("u", "l", "r")) glyph = "┴";
832
- canvas.setCell(row, col, glyph, color);
794
+ canvas.mergeCell(row, col, newDirs, color);
833
795
  }
834
796
 
835
797
  /**
@@ -853,7 +815,6 @@ export class GraphView implements Component {
853
815
  ): string {
854
816
  const bg = hexBg(this.graphTheme.bg);
855
817
  const sorted = cards.slice().sort((a, b) => a.startCol - b.startCol);
856
- const edgeVisible = this._splitVisible(edgeRow);
857
818
  let cursor = 0;
858
819
  let out = "";
859
820
  for (const card of sorted) {
@@ -861,69 +822,27 @@ export class GraphView implements Component {
861
822
  // Edge segment up to card start — prepend bg so empty cells
862
823
  // in this stretch keep the body bg instead of falling back
863
824
  // to the terminal default once any prior RESET fired.
864
- out += `${bg}${this._sliceVisible(edgeVisible, cursor, card.startCol)}`;
825
+ out += `${bg}${this._edgeSegment(edgeRow, cursor, card.startCol)}`;
865
826
  cursor = card.startCol;
866
827
  }
867
828
  out += card.line;
868
829
  cursor += card.width;
869
830
  }
870
831
  // Trailing edge tail — same bg re-priming.
871
- out += `${bg}${this._sliceVisible(edgeVisible, cursor, edgeVisible.length)}`;
832
+ out += `${bg}${this._edgeSegment(edgeRow, cursor, Math.max(cursor, visibleWidth(edgeRow)))}`;
872
833
  return out;
873
834
  }
874
835
 
875
- /**
876
- * Split a styled ANSI string into per-cell entries, each carrying the
877
- * style prefix that should apply when rendering that cell. Used by
878
- * `_sliceVisible` so we can cut the edge line at arbitrary columns
879
- * without breaking ANSI sequences.
880
- */
881
- private _splitVisible(line: string): Array<{ prefix: string; ch: string }> {
882
- const cells: Array<{ prefix: string; ch: string }> = [];
883
- let i = 0;
884
- let pending = "";
885
- while (i < line.length) {
886
- const ch = line[i]!;
887
- if (ch === "\x1b" && line[i + 1] === "[") {
888
- const end = line.indexOf("m", i);
889
- if (end === -1) break;
890
- pending += line.slice(i, end + 1);
891
- i = end + 1;
892
- continue;
893
- }
894
- cells.push({ prefix: pending, ch });
895
- pending = "";
896
- i += 1;
897
- }
898
- return cells;
836
+ private _edgeSegment(line: string, fromCol: number, toCol: number): string {
837
+ if (fromCol >= toCol) return "";
838
+ const segment = this._sliceColumns(line, fromCol, toCol);
839
+ const visible = visibleWidth(segment);
840
+ return `${segment}${" ".repeat(Math.max(0, toCol - fromCol - visible))}`;
899
841
  }
900
842
 
901
- private _sliceVisible(
902
- cells: Array<{ prefix: string; ch: string }>,
903
- fromCol: number,
904
- toCol: number,
905
- ): string {
843
+ private _sliceColumns(line: string, fromCol: number, toCol: number): string {
906
844
  if (fromCol >= toCol) return "";
907
- let out = "";
908
- let lastPrefix = "";
909
- for (let c = fromCol; c < toCol; c++) {
910
- const cell = cells[c];
911
- if (cell) {
912
- if (cell.prefix && cell.prefix !== lastPrefix) {
913
- out += cell.prefix;
914
- lastPrefix = cell.prefix;
915
- }
916
- out += cell.ch;
917
- } else {
918
- if (lastPrefix !== "") {
919
- out += RESET;
920
- lastPrefix = "";
921
- }
922
- out += " ";
923
- }
924
- }
925
- if (lastPrefix !== "") out += RESET;
926
- return out;
845
+ return sliceByColumn(line, fromCol, toCol - fromCol, true);
927
846
  }
928
847
 
929
848
  // -------------------------------------------------------------------------
@@ -955,21 +874,16 @@ export class GraphView implements Component {
955
874
  ({ key, label }) =>
956
875
  `${text}${BOLD}${key}${RESET}${chromeBg} ${muted}${label}${RESET}${chromeBg}`,
957
876
  );
958
- const hintsStyled = segments.join(sep);
959
- const hintsVisibleLen =
960
- HINT_KEYS.reduce((sum, h) => sum + h.key.length + 1 + h.label.length, 0) +
961
- (HINT_KEYS.length - 1) * 5; // " · "
877
+ const hintsStyledRaw = segments.join(sep);
962
878
 
963
879
  const leftEdgePad = 1;
964
880
  const rightEdgePad = 2;
965
- const fillerVisible = Math.max(
966
- 2,
967
- width - leftEdgePad - pillW - hintsVisibleLen - rightEdgePad,
968
- );
881
+ const hintsBudget = Math.max(0, width - leftEdgePad - pillW - rightEdgePad);
882
+ const hintsStyled = truncateToWidth(hintsStyledRaw, hintsBudget, "");
883
+ const hintsVisibleLen = visibleWidth(hintsStyled);
884
+ const fillerVisible = Math.max(0, hintsBudget - hintsVisibleLen);
969
885
  const filler = " ".repeat(fillerVisible);
970
- const blankAcross = " ".repeat(
971
- fillerVisible + hintsVisibleLen + rightEdgePad,
972
- );
886
+ const blankAcross = " ".repeat(Math.max(0, width - leftEdgePad - pillW));
973
887
 
974
888
  return [
975
889
  `${chromeBg} ${RESET}${top}${chromeBg}${blankAcross}${RESET}`,
@@ -1033,19 +947,20 @@ export class GraphView implements Component {
1033
947
  leftPad: number,
1034
948
  totalWidth: number,
1035
949
  ): string {
1036
- const baseCells = this._splitVisible(base);
1037
950
  const overlayWidth = Math.min(
1038
951
  Math.max(0, totalWidth - leftPad),
1039
952
  visibleWidth(overlay),
1040
953
  );
1041
- const left = this._sliceVisible(baseCells, 0, leftPad);
954
+ const left = this._sliceColumns(base, 0, leftPad);
1042
955
  const panel = truncateToWidth(overlay, overlayWidth, "", true);
1043
- const right = this._sliceVisible(
1044
- baseCells,
956
+ const right = this._sliceColumns(
957
+ base,
1045
958
  leftPad + overlayWidth,
1046
959
  totalWidth,
1047
960
  );
1048
- return `${left}${panel}${right}`;
961
+ const merged = `${left}${panel}${right}`;
962
+ const pad = Math.max(0, totalWidth - visibleWidth(merged));
963
+ return `${merged}${hexBg(this.graphTheme.bg)}${" ".repeat(pad)}${RESET}`;
1049
964
  }
1050
965
 
1051
966
  private _duration(stage: StageSnapshot): string {
@@ -1187,7 +1102,7 @@ export class GraphView implements Component {
1187
1102
  return true;
1188
1103
  }
1189
1104
  // `q` kills the active run (no confirm). `h` hides the pane via
1190
- // the overlay's setHidden() flag (not unmount); Escape closes.
1105
+ // the overlay's setHidden() flag (not unmount); Escape/Ctrl+C closes.
1191
1106
  if (matchesKey(data, "q")) {
1192
1107
  const run = this._getCurrentRun();
1193
1108
  if (run && run.endedAt === undefined && this.onKill) {
@@ -1200,7 +1115,7 @@ export class GraphView implements Component {
1200
1115
  this.onHide();
1201
1116
  return true;
1202
1117
  }
1203
- if (matchesKey(data, "escape") || data === "\x1b") {
1118
+ if (matchesKey(data, "escape") || data === "\x1b" || data === "\x03") {
1204
1119
  this.onClose?.();
1205
1120
  return true;
1206
1121
  }
@@ -22,6 +22,7 @@
22
22
  import type { RunSnapshot } from "../shared/store-types.js";
23
23
  import type { GraphTheme } from "./graph-theme.js";
24
24
  import { hexToAnsi, hexBg, RESET, BOLD } from "./color-utils.js";
25
+ import { truncateToWidth, visibleWidth } from "./text-helpers.js";
25
26
 
26
27
  export interface HeaderOpts {
27
28
  width: number;
@@ -72,11 +73,12 @@ export function renderOutlinePill(
72
73
  ): { top: string; mid: string; bot: string; visibleWidth: number } {
73
74
  const bc = hexToAnsi(borderHex);
74
75
  const padded = ` ${label} `;
75
- const inner = "─".repeat(padded.length);
76
+ const paddedWidth = visibleWidth(padded);
77
+ const inner = "─".repeat(paddedWidth);
76
78
  const top = `${chromeBg}${bc}╭${inner}╮${RESET}`;
77
79
  const mid = `${chromeBg}${bc}│${BOLD}${padded}${RESET}${chromeBg}${bc}│${RESET}`;
78
80
  const bot = `${chromeBg}${bc}╰${inner}╯${RESET}`;
79
- return { top, mid, bot, visibleWidth: padded.length + 2 };
81
+ return { top, mid, bot, visibleWidth: paddedWidth + 2 };
80
82
  }
81
83
 
82
84
  /**
@@ -96,42 +98,56 @@ export function renderBandHeader(opts: BandHeaderOpts): string[] {
96
98
  const { top: pillTop, mid: pillMid, bot: pillBot, visibleWidth: pillW } =
97
99
  renderOutlinePill(label, accentHex, chromeBg);
98
100
 
99
- // Subtitle slot — quieter than the pill. Two-space gutter on each
100
- // side keeps the pill from kissing the subtitle.
101
- const subtitleVisible = subtitle.length > 0 ? ` ${subtitle}` : "";
102
- const subtitleStyled =
103
- subtitle.length > 0
104
- ? `${chromeBg} ${muted}${subtitle}${RESET}${chromeBg}`
105
- : `${chromeBg}`;
106
-
107
101
  // Right-side count badges. Status colour per badge, 2ch gap between.
108
102
  const rightVisible = badges.map((b) => b.text).join(" ");
103
+ const rightW = visibleWidth(rightVisible);
109
104
  const rightStyled = badges
110
105
  .map((b) => `${hexToAnsi(b.fg)}${b.text}${RESET}${chromeBg}`)
111
106
  .join(`${chromeBg} `);
112
107
 
113
108
  const leftEdgePad = 1;
114
109
  const rightEdgePad = 2;
110
+
111
+ // Subtitle slot — quieter than the pill. Two-space gutter keeps the
112
+ // pill from kissing the subtitle. Budget with terminal cell widths so
113
+ // long CJK/emoji/combining run names cannot push the chrome past `width`.
114
+ const subtitleBudget = Math.max(
115
+ 0,
116
+ width - leftEdgePad - pillW - rightW - rightEdgePad,
117
+ );
118
+ const subtitleTextBudget = Math.max(0, subtitleBudget - 2);
119
+ const subtitleText =
120
+ subtitle.length > 0 && subtitleTextBudget > 0
121
+ ? truncateToWidth(subtitle, subtitleTextBudget, "…")
122
+ : "";
123
+ const subtitleVisible = subtitleText.length > 0 ? ` ${subtitleText}` : "";
124
+ const subtitleW = visibleWidth(subtitleVisible);
125
+ const subtitleStyled =
126
+ subtitleText.length > 0
127
+ ? `${chromeBg} ${muted}${subtitleText}${RESET}${chromeBg}`
128
+ : `${chromeBg}`;
129
+
115
130
  const fillerTop = Math.max(
116
131
  0,
117
- width - leftEdgePad - pillW - subtitleVisible.length - rightEdgePad,
132
+ width - leftEdgePad - pillW - subtitleW - rightEdgePad,
118
133
  );
119
134
  const fillerMid = Math.max(
120
135
  0,
121
- width -
122
- leftEdgePad -
123
- pillW -
124
- subtitleVisible.length -
125
- rightVisible.length -
126
- rightEdgePad,
136
+ width - leftEdgePad - pillW - subtitleW - rightW - rightEdgePad,
127
137
  );
128
138
 
129
- const subtitleBlank = " ".repeat(subtitleVisible.length);
139
+ const fitChromeLine = (line: string): string => {
140
+ const fitted = truncateToWidth(line, width, "");
141
+ const pad = Math.max(0, width - visibleWidth(fitted));
142
+ return `${fitted}${chromeBg}${" ".repeat(pad)}${RESET}`;
143
+ };
144
+
145
+ const subtitleBlank = " ".repeat(subtitleW);
130
146
  const top = `${chromeBg} ${RESET}${pillTop}${chromeBg}${subtitleBlank}${" ".repeat(fillerTop)}${" ".repeat(rightEdgePad)}${RESET}`;
131
147
  const mid = `${chromeBg} ${RESET}${pillMid}${subtitleStyled}${" ".repeat(fillerMid)}${rightStyled}${chromeBg}${" ".repeat(rightEdgePad)}${RESET}`;
132
148
  const bot = `${chromeBg} ${RESET}${pillBot}${chromeBg}${subtitleBlank}${" ".repeat(fillerTop)}${" ".repeat(rightEdgePad)}${RESET}`;
133
149
 
134
- return [top, mid, bot];
150
+ return [fitChromeLine(top), fitChromeLine(mid), fitChromeLine(bot)];
135
151
  }
136
152
 
137
153
  /**
@@ -157,7 +173,7 @@ export function renderHeader(run: RunSnapshot, opts: HeaderOpts): string[] {
157
173
  if (counts.completed > 0) badges.push({ text: `✓ ${counts.completed}`, fg: theme.success });
158
174
  if (counts.running > 0) badges.push({ text: `● ${counts.running}`, fg: theme.warning });
159
175
  if (counts.awaiting_input > 0) badges.push({ text: `↵ ${counts.awaiting_input}`, fg: theme.info });
160
- if (counts.paused > 0) badges.push({ text: `⏸ ${counts.paused}`, fg: theme.accent });
176
+ if (counts.paused > 0) badges.push({ text: `❚❚ ${counts.paused}`, fg: theme.warning });
161
177
  if (counts.pending > 0) badges.push({ text: `○ ${counts.pending}`, fg: theme.dim });
162
178
  if (counts.failed > 0) badges.push({ text: `✗ ${counts.failed}`, fg: theme.error });
163
179