@bastani/atomic 0.8.4-0 → 0.8.5-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/CHANGELOG.md +16 -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
@@ -22,7 +22,7 @@
22
22
  * ╰──────────────────────────────────────────╯
23
23
  * select · required · How aggressively to scope the work.
24
24
  *
25
- * tab next · shift+tab prev · ctrl+s run · esc cancel
25
+ * tab next · shift+tab prev · ctrl+enter run · esc cancel
26
26
  *
27
27
  * Field-type renderers:
28
28
  * - string / number : single-row text input with blinking cursor
@@ -40,7 +40,7 @@
40
40
  import type { WorkflowInputEntry } from "../extension/render-result.js";
41
41
  import type { GraphTheme } from "./graph-theme.js";
42
42
  import { paint } from "./color-utils.js";
43
- import { truncateToWidth, visibleWidth } from "./text-helpers.js";
43
+ import { matchesKey, truncateToWidth, visibleWidth } from "./text-helpers.js";
44
44
  import {
45
45
  type KeybindingsLike,
46
46
  deleteRange,
@@ -75,8 +75,8 @@ export interface InputsPickerState {
75
75
  confirmOpen: boolean;
76
76
  /**
77
77
  * Set of field indices that failed validation on the most recent submit
78
- * attempt. Used to dim the `ctrl+s` hint and to highlight a field if the
79
- * user retries with required fields still empty.
78
+ * attempt. Used to dim the run hint and to highlight a field if the user
79
+ * retries with required fields still empty.
80
80
  */
81
81
  invalidIndices: readonly number[];
82
82
  /** Cursor offset within the focused single-line text field. */
@@ -211,8 +211,8 @@ export function coerceValues(
211
211
 
212
212
  /**
213
213
  * Return the reason why `field` is invalid for `value`, or `null` if valid.
214
- * Used both to flag fields on submit and to drive the dim state of the
215
- * `ctrl+s run` footer hint.
214
+ * Used both to flag fields on submit and to drive the dim state of the run
215
+ * key hint.
216
216
  */
217
217
  export function invalidForField(
218
218
  field: WorkflowInputEntry,
@@ -256,6 +256,158 @@ function computeInvalid(
256
256
 
257
257
  const dimSep = (theme: GraphTheme): string => paint(" · ", theme.dim);
258
258
 
259
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
260
+
261
+ function graphemes(text: string): string[] {
262
+ return Array.from(graphemeSegmenter.segment(text), (s) => s.segment);
263
+ }
264
+
265
+ function previousGraphemeOffset(text: string, caret: number): number {
266
+ const c = Math.max(0, Math.min(caret, text.length));
267
+ let prev = 0;
268
+ for (const s of graphemeSegmenter.segment(text)) {
269
+ if (s.index >= c) break;
270
+ prev = s.index;
271
+ }
272
+ return prev;
273
+ }
274
+
275
+ function nextGraphemeOffset(text: string, caret: number): number {
276
+ const c = Math.max(0, Math.min(caret, text.length));
277
+ for (const s of graphemeSegmenter.segment(text)) {
278
+ if (s.index >= c) return Math.min(text.length, s.index + s.segment.length);
279
+ if (s.index + s.segment.length > c) return s.index + s.segment.length;
280
+ }
281
+ return text.length;
282
+ }
283
+
284
+ function clampGraphemeOffset(text: string, caret: number): number {
285
+ const c = Math.max(0, Math.min(caret, text.length));
286
+ if (c === text.length) return c;
287
+ for (const s of graphemeSegmenter.segment(text)) {
288
+ if (s.index === c) return c;
289
+ if (s.index > c) break;
290
+ }
291
+ return previousGraphemeOffset(text, c);
292
+ }
293
+
294
+ function headToWidth(text: string, width: number): string {
295
+ if (width <= 0) return "";
296
+ let out = "";
297
+ let used = 0;
298
+ for (const g of graphemes(text)) {
299
+ const w = visibleWidth(g);
300
+ if (used + w > width) break;
301
+ out += g;
302
+ used += w;
303
+ }
304
+ return out;
305
+ }
306
+
307
+ function tailToWidth(text: string, width: number): string {
308
+ if (width <= 0) return "";
309
+ let out = "";
310
+ let used = 0;
311
+ const gs = graphemes(text);
312
+ for (let i = gs.length - 1; i >= 0; i--) {
313
+ const g = gs[i]!;
314
+ const w = visibleWidth(g);
315
+ if (used + w > width) break;
316
+ out = g + out;
317
+ used += w;
318
+ }
319
+ return out;
320
+ }
321
+
322
+ function isPrintableGrapheme(data: string): boolean {
323
+ if (data.length === 0 || data.includes("\x1b")) return false;
324
+ for (const ch of data) {
325
+ const code = ch.codePointAt(0);
326
+ if (code === undefined || code < 0x20 || code === 0x7f) return false;
327
+ }
328
+ return graphemes(data).length === 1;
329
+ }
330
+
331
+ interface TextLayoutLine {
332
+ text: string;
333
+ start: number;
334
+ end: number;
335
+ }
336
+
337
+ function layoutEditableText(raw: string, usable: number): TextLayoutLine[] {
338
+ const width = Math.max(1, Math.floor(usable));
339
+ const lines: TextLayoutLine[] = [];
340
+ let line = "";
341
+ let lineStart = 0;
342
+ let lineWidth = 0;
343
+ for (const s of graphemeSegmenter.segment(raw)) {
344
+ const offset = s.index;
345
+ const g = s.segment;
346
+ if (g === "\n") {
347
+ lines.push({ text: line, start: lineStart, end: offset });
348
+ line = "";
349
+ lineStart = offset + g.length;
350
+ lineWidth = 0;
351
+ continue;
352
+ }
353
+ const w = visibleWidth(g);
354
+ if (line !== "" && lineWidth + w > width) {
355
+ lines.push({ text: line, start: lineStart, end: offset });
356
+ line = "";
357
+ lineStart = offset;
358
+ lineWidth = 0;
359
+ }
360
+ line += g;
361
+ lineWidth += w;
362
+ if (lineWidth >= width) {
363
+ lines.push({ text: line, start: lineStart, end: offset + g.length });
364
+ line = "";
365
+ lineStart = offset + g.length;
366
+ lineWidth = 0;
367
+ }
368
+ }
369
+ lines.push({ text: line, start: lineStart, end: raw.length });
370
+ return lines;
371
+ }
372
+
373
+ function visualColumnAt(text: string, caret: number): number {
374
+ return visibleWidth(text.slice(0, clampGraphemeOffset(text, caret)));
375
+ }
376
+
377
+ function offsetAtVisualColumn(text: string, targetCol: number): number {
378
+ let col = 0;
379
+ for (const s of graphemeSegmenter.segment(text)) {
380
+ const w = visibleWidth(s.segment);
381
+ if (col + w > targetCol) return s.index;
382
+ col += w;
383
+ }
384
+ return text.length;
385
+ }
386
+
387
+ function caretLineUp(raw: string, caret: number): number | null {
388
+ const safe = clampGraphemeOffset(raw, caret);
389
+ const lineStartOffset = raw.lastIndexOf("\n", safe - 1) + 1;
390
+ if (lineStartOffset === 0) return null;
391
+ const prevLineEnd = lineStartOffset - 1;
392
+ const prevLineStart = raw.lastIndexOf("\n", prevLineEnd - 1) + 1;
393
+ const col = visualColumnAt(raw.slice(lineStartOffset, safe), raw.slice(lineStartOffset, safe).length);
394
+ const prevLine = raw.slice(prevLineStart, prevLineEnd);
395
+ return prevLineStart + offsetAtVisualColumn(prevLine, col);
396
+ }
397
+
398
+ function caretLineDown(raw: string, caret: number): number | null {
399
+ const safe = clampGraphemeOffset(raw, caret);
400
+ const nextNl = raw.indexOf("\n", safe);
401
+ if (nextNl === -1) return null;
402
+ const lineStartOffset = raw.lastIndexOf("\n", safe - 1) + 1;
403
+ const col = visualColumnAt(raw.slice(lineStartOffset, safe), raw.slice(lineStartOffset, safe).length);
404
+ const nextLineStart = nextNl + 1;
405
+ const nextNlAfter = raw.indexOf("\n", nextLineStart);
406
+ const nextLineEnd = nextNlAfter === -1 ? raw.length : nextNlAfter;
407
+ const nextLine = raw.slice(nextLineStart, nextLineEnd);
408
+ return nextLineStart + offsetAtVisualColumn(nextLine, col);
409
+ }
410
+
259
411
  /**
260
412
  * Render a single field's three-row block: top border with title, content
261
413
  * row (variable per type), bottom border, then the caption row underneath.
@@ -377,15 +529,57 @@ function renderFieldContent(
377
529
  }
378
530
 
379
531
  if (field.type === "text") {
380
- // 3-row scrolling textarea — keeps the cursor line visible.
532
+ // 3-row scrolling textarea — wrap by terminal cell width and keep the
533
+ // cursor's visual row in view. Newlines create hard row breaks.
381
534
  const ROWS = 3;
382
- const allLines = raw.split("\n");
383
- const start = Math.max(0, allLines.length - ROWS);
535
+ if (raw === "") {
536
+ return Array.from({ length: ROWS }, (_, i) =>
537
+ i === 0
538
+ ? renderInlineText("", focused, cursorOn, usable, theme, field.placeholder, true)
539
+ : padLine("", usable),
540
+ );
541
+ }
542
+ const layout = layoutEditableText(raw, usable);
543
+ const safeCaret = clampGraphemeOffset(raw, caret);
544
+ let cursorRow = layout.length - 1;
545
+ for (let i = 0; i < layout.length; i++) {
546
+ const line = layout[i]!;
547
+ const next = layout[i + 1];
548
+ if (safeCaret >= line.start && safeCaret < line.end) {
549
+ cursorRow = i;
550
+ break;
551
+ }
552
+ if (safeCaret === line.end) {
553
+ cursorRow = next?.start === safeCaret ? i + 1 : i;
554
+ }
555
+ }
556
+ cursorRow = Math.max(0, Math.min(cursorRow, layout.length - 1));
557
+ const start = focused
558
+ ? Math.max(0, Math.min(cursorRow - ROWS + 1, layout.length - ROWS))
559
+ : Math.max(0, layout.length - ROWS);
384
560
  const rows: string[] = [];
385
561
  for (let i = 0; i < ROWS; i++) {
386
- const line = allLines[start + i] ?? "";
387
- const isCursorLine = focused && i === Math.min(ROWS - 1, allLines.length - 1 - start);
388
- rows.push(renderInlineText(line, isCursorLine, cursorOn, usable, theme, field.placeholder, raw === ""));
562
+ const rowIdx = start + i;
563
+ const line = layout[rowIdx];
564
+ if (!line) {
565
+ rows.push(padLine("", usable));
566
+ continue;
567
+ }
568
+ const lineCaret = safeCaret >= line.start && safeCaret <= line.end
569
+ ? safeCaret - line.start
570
+ : line.text.length;
571
+ rows.push(
572
+ renderInlineText(
573
+ line.text,
574
+ focused && rowIdx === cursorRow,
575
+ cursorOn,
576
+ usable,
577
+ theme,
578
+ field.placeholder,
579
+ false,
580
+ lineCaret,
581
+ ),
582
+ );
389
583
  }
390
584
  return rows;
391
585
  }
@@ -415,23 +609,31 @@ function renderInlineText(
415
609
  if (ph === "") {
416
610
  return padLine(showCursor ? paint("▋", theme.accent) : " ", usable);
417
611
  }
418
- const first = ph.slice(0, 1);
419
- const rest = ph.slice(1);
612
+ const [first = "", ...rest] = graphemes(ph);
420
613
  const head = showCursor
421
614
  ? paint(first, theme.bg, { bg: theme.accent })
422
615
  : paint(first, theme.dim);
423
- return padLine(head + paint(rest, theme.dim), usable);
616
+ return padLine(head + paint(rest.join(""), theme.dim), usable);
617
+ }
618
+ const safe = clampGraphemeOffset(value, caret ?? value.length);
619
+ const beforeFull = value.slice(0, safe);
620
+ const afterFull = value.slice(safe);
621
+ const [at = ""] = graphemes(afterFull);
622
+ const afterRest = at === "" ? "" : afterFull.slice(at.length);
623
+ const cursorPlain = showCursor ? (at !== "" ? at : "▋") : at;
624
+ const cursorWidth = Math.max(1, visibleWidth(cursorPlain));
625
+ const totalWidth = visibleWidth(beforeFull) + cursorWidth + visibleWidth(showCursor ? afterRest : afterFull.slice(at.length));
626
+ let before = beforeFull;
627
+ let after = showCursor ? afterRest : afterFull.slice(at.length);
628
+ if (totalWidth > usable) {
629
+ before = tailToWidth(beforeFull, Math.max(0, usable - cursorWidth));
630
+ after = headToWidth(showCursor ? afterRest : afterFull.slice(at.length), Math.max(0, usable - visibleWidth(before) - cursorWidth));
424
631
  }
425
- const c = caret ?? value.length;
426
- const safe = Math.max(0, Math.min(c, value.length));
427
- const before = value.slice(0, safe);
428
- const at = value.slice(safe, safe + 1);
429
- const after = value.slice(safe + 1);
430
632
  const cursorCell = showCursor
431
633
  ? at !== ""
432
634
  ? paint(at, theme.bg, { bg: theme.accent })
433
635
  : paint("▋", theme.accent)
434
- : at;
636
+ : paint(at, theme.text);
435
637
  return padLine(paint(before, theme.text) + cursorCell + paint(after, theme.text), usable);
436
638
  }
437
639
 
@@ -469,7 +671,8 @@ export function renderInputsPicker(opts: InputsPickerRenderOpts): string[] {
469
671
  // Section label with field counter (1-based). When the terminal is too
470
672
  // narrow to hold both, the counter is the priority — drop "INPUTS" first
471
673
  // so the user always knows which field they're on.
472
- const counter = `${state.focusedIdx + 1} / ${fields.length}`;
674
+ const focusTargetCount = fields.length;
675
+ const counter = `${Math.min(state.focusedIdx + 1, focusTargetCount)} / ${focusTargetCount}`;
473
676
  const labelLeft =
474
677
  paint("▎ ", theme.mauve) + paint("INPUTS", theme.textMuted, { bold: true });
475
678
  const labelLen = visibleWidth(labelLeft);
@@ -498,14 +701,12 @@ export function renderInputsPicker(opts: InputsPickerRenderOpts): string[] {
498
701
  lines.push(""); // gap between fields
499
702
  }
500
703
 
501
- // Footer hints — tiered for narrow widths. The widest form ends up around
502
- // 57 visible cells; we step down to keys-with-labels-tight, keys-only,
503
- // and finally essentials-only when the terminal cannot hold the row. The
504
- // `ctrl+s` hint dims when any field is currently invalid.
505
704
  const anyInvalid = computeInvalid(fields, state.rawText).length > 0;
506
- const submitColor = anyInvalid ? theme.dim : theme.text;
507
- const submitLabelColor = anyInvalid ? theme.dim : theme.textMuted;
508
- lines.push(renderFooterHints(width, theme, submitColor, submitLabelColor));
705
+
706
+ // Footer hints tiered for narrow widths. The widest form ends up around
707
+ // 61 visible cells; we step down to keys-with-labels-tight, keys-only,
708
+ // and finally essentials-only when the terminal cannot hold the row.
709
+ lines.push(renderFooterHints(width, theme, anyInvalid));
509
710
 
510
711
  if (state.confirmOpen) {
511
712
  lines.push("");
@@ -517,47 +718,41 @@ export function renderInputsPicker(opts: InputsPickerRenderOpts): string[] {
517
718
  /**
518
719
  * Footer hint row, tier-degraded so it never wraps on resize. Tiers:
519
720
  *
520
- * wide (≥ widest): tab next · shift+tab prev · ctrl+s run · esc cancel
521
- * medium (≥ keys): tab · shift+tab · ctrl+s · esc
522
- * tight (≥ short): tab · ⇧tab · ⌃s · esc
523
- * narrow (else): ⌃s · esc
524
- *
525
- * The `ctrl+s` hint always survives — it is the only "run" affordance — and
526
- * `esc cancel` always survives so the user can back out.
721
+ * wide (≥ widest): tab next · shift+tab prev · ctrl+enter run · esc cancel
722
+ * medium (≥ keys): tab · shift+tab · ctrl+enter · esc
723
+ * tight (≥ short): tab · ⇧tab · ⌃↵ · esc
724
+ * narrow (else): ⌃↵ · esc
527
725
  */
528
- function renderFooterHints(
529
- width: number,
530
- theme: GraphTheme,
531
- submitColor: string,
532
- submitLabelColor: string,
533
- ): string {
726
+ function renderFooterHints(width: number, theme: GraphTheme, submitDisabled: boolean): string {
534
727
  const sep = dimSep(theme);
535
728
  const sepWidth = 5; // " · "
536
- const hint = (key: string, label: string, kc: string, lc: string): string =>
537
- paint(key, kc) + " " + paint(label, lc);
538
- const keyOnly = (key: string, kc: string): string => paint(key, kc);
729
+ const submitColor = submitDisabled ? theme.dim : theme.text;
730
+ const submitLabelColor = submitDisabled ? theme.dim : theme.textMuted;
731
+ const hint = (key: string, label: string, keyColor = theme.text, labelColor = theme.textMuted): string =>
732
+ paint(key, keyColor) + " " + paint(label, labelColor);
733
+ const keyOnly = (key: string, keyColor = theme.text): string => paint(key, keyColor);
539
734
 
540
735
  const wide = [
541
- { width: 8, render: () => hint("tab", "next", theme.text, theme.textMuted) },
542
- { width: 14, render: () => hint("shift+tab", "prev", theme.text, theme.textMuted) },
543
- { width: 10, render: () => hint("ctrl+s", "run", submitColor, submitLabelColor) },
544
- { width: 10, render: () => hint("esc", "cancel", theme.text, theme.textMuted) },
736
+ { width: 8, render: () => hint("tab", "Next") },
737
+ { width: 14, render: () => hint("shift+tab", "Prev") },
738
+ { width: 14, render: () => hint("ctrl+enter", "Run", submitColor, submitLabelColor) },
739
+ { width: 10, render: () => hint("esc", "Cancel") },
545
740
  ];
546
741
  const medium = [
547
- { width: 3, render: () => keyOnly("tab", theme.text) },
548
- { width: 9, render: () => keyOnly("shift+tab", theme.text) },
549
- { width: 6, render: () => keyOnly("ctrl+s", submitColor) },
550
- { width: 3, render: () => keyOnly("esc", theme.text) },
742
+ { width: 3, render: () => keyOnly("tab") },
743
+ { width: 9, render: () => keyOnly("shift+tab") },
744
+ { width: 10, render: () => keyOnly("ctrl+enter", submitColor) },
745
+ { width: 6, render: () => keyOnly("esc") },
551
746
  ];
552
747
  const tight = [
553
- { width: 3, render: () => keyOnly("tab", theme.text) },
554
- { width: 4, render: () => keyOnly("⇧tab", theme.text) },
555
- { width: 2, render: () => keyOnly("⌃s", submitColor) },
556
- { width: 3, render: () => keyOnly("esc", theme.text) },
748
+ { width: 3, render: () => keyOnly("tab") },
749
+ { width: 4, render: () => keyOnly("⇧tab") },
750
+ { width: 2, render: () => keyOnly("⌃↵", submitColor) },
751
+ { width: 6, render: () => keyOnly("esc") },
557
752
  ];
558
753
  const narrow = [
559
- { width: 2, render: () => keyOnly("⌃s", submitColor) },
560
- { width: 3, render: () => keyOnly("esc", theme.text) },
754
+ { width: 2, render: () => keyOnly("⌃↵", submitColor) },
755
+ { width: 6, render: () => keyOnly("esc") },
561
756
  ];
562
757
 
563
758
  for (const tier of [wide, medium, tight, narrow]) {
@@ -567,7 +762,7 @@ function renderFooterHints(
567
762
  }
568
763
  }
569
764
  // Truly tiny terminal — show just the run+cancel keys joined by a single space.
570
- return paint("⌃s", submitColor) + " " + paint("esc", theme.text);
765
+ return paint("⌃↵", submitColor) + " " + paint("esc", theme.text);
571
766
  }
572
767
 
573
768
  /**
@@ -632,9 +827,9 @@ function shortVal(s: string): string {
632
827
  * left / right — select: cycle choices; boolean: flip; text: caret
633
828
  * space — boolean: flip
634
829
  * enter — text: newline; otherwise: next field
635
- * ctrl+s — open confirm modal (if all required filled)
830
+ * ctrl+enter — open confirm modal (if all required filled)
636
831
  * backspace — delete char left of caret
637
- * esc — close picker without running
832
+ * esc / ctrl+c — close picker without running
638
833
  *
639
834
  * Keys (confirm modal mode):
640
835
  * y / enter — run
@@ -650,7 +845,7 @@ export function handleInputsPickerInput(
650
845
  // Defensive: a workflow with zero declared inputs shouldn't reach the
651
846
  // picker (we gate on `fields.length > 0` at the open() site), but if
652
847
  // it does, treat any keystroke as a noop and let the host close us.
653
- if (key === "\x1b") return { kind: "cancel" };
848
+ if (isCancelKey(key)) return { kind: "cancel" };
654
849
  return { kind: "noop" };
655
850
  }
656
851
  if (state.confirmOpen) return handleConfirmKey(key, state, fields);
@@ -663,33 +858,21 @@ function handleFormKey(
663
858
  fields: readonly WorkflowInputEntry[],
664
859
  kb: KeybindingsLike | undefined,
665
860
  ): InputsPickerAction {
666
- const field = fields[state.focusedIdx]!;
667
- const name = field.name;
668
- const cur = state.rawText[name] ?? "";
669
-
670
861
  // ── Global navigation (workflow form contract, not Pi actions) ──
671
- if (key === "\x1b") return { kind: "cancel" };
672
- if (key === "\t") {
862
+ if (isCancelKey(key)) return { kind: "cancel" };
863
+ if (matchesKey(key, "ctrl+enter")) return attemptPickerSubmit(state, fields);
864
+ if (matchesKey(key, "tab")) {
673
865
  moveFocus(state, fields, +1);
674
866
  return { kind: "noop" };
675
867
  }
676
- if (key === "\x1b[Z") {
868
+ if (matchesKey(key, "shift+tab")) {
677
869
  moveFocus(state, fields, -1);
678
870
  return { kind: "noop" };
679
871
  }
680
- if (key === "\x13") {
681
- // ctrl+s attempt submit
682
- const invalid = computeInvalid(fields, state.rawText);
683
- if (invalid.length > 0) {
684
- state.invalidIndices = invalid;
685
- state.focusedIdx = invalid[0]!;
686
- state.caret = (state.rawText[fields[state.focusedIdx]!.name] ?? "").length;
687
- return { kind: "noop" };
688
- }
689
- state.invalidIndices = [];
690
- state.confirmOpen = true;
691
- return { kind: "noop" };
692
- }
872
+
873
+ const field = fields[state.focusedIdx]!;
874
+ const name = field.name;
875
+ const cur = state.rawText[name] ?? "";
693
876
 
694
877
  // ── Per-type edits ──
695
878
  if (field.type === "select") {
@@ -705,10 +888,24 @@ function handleFormKey(
705
888
  const caret = Math.max(0, Math.min(state.caret, cur.length));
706
889
 
707
890
  if (matchesAction(kb, key, "tui.editor.cursorUp")) {
891
+ if (field.type === "text") {
892
+ const nextCaret = caretLineUp(cur, caret);
893
+ if (nextCaret !== null) {
894
+ state.caret = nextCaret;
895
+ return { kind: "noop" };
896
+ }
897
+ }
708
898
  moveFocus(state, fields, -1);
709
899
  return { kind: "noop" };
710
900
  }
711
901
  if (matchesAction(kb, key, "tui.editor.cursorDown")) {
902
+ if (field.type === "text") {
903
+ const nextCaret = caretLineDown(cur, caret);
904
+ if (nextCaret !== null) {
905
+ state.caret = nextCaret;
906
+ return { kind: "noop" };
907
+ }
908
+ }
712
909
  moveFocus(state, fields, +1);
713
910
  return { kind: "noop" };
714
911
  }
@@ -729,11 +926,11 @@ function handleFormKey(
729
926
  return { kind: "noop" };
730
927
  }
731
928
  if (matchesAction(kb, key, "tui.editor.cursorLeft")) {
732
- state.caret = Math.max(0, caret - 1);
929
+ state.caret = previousGraphemeOffset(cur, caret);
733
930
  return { kind: "noop" };
734
931
  }
735
932
  if (matchesAction(kb, key, "tui.editor.cursorRight")) {
736
- state.caret = Math.min(cur.length, caret + 1);
933
+ state.caret = nextGraphemeOffset(cur, caret);
737
934
  return { kind: "noop" };
738
935
  }
739
936
  if (matchesAction(kb, key, "tui.editor.deleteWordBackward")) {
@@ -766,7 +963,7 @@ function handleFormKey(
766
963
  }
767
964
  if (matchesAction(kb, key, "tui.editor.deleteCharBackward")) {
768
965
  if (caret > 0) {
769
- const r = deleteRange(cur, caret - 1, caret, caret);
966
+ const r = deleteRange(cur, previousGraphemeOffset(cur, caret), caret, caret);
770
967
  state.rawText[name] = r.text;
771
968
  state.caret = r.caret;
772
969
  }
@@ -774,7 +971,7 @@ function handleFormKey(
774
971
  }
775
972
  if (matchesAction(kb, key, "tui.editor.deleteCharForward")) {
776
973
  if (caret < cur.length) {
777
- const r = deleteRange(cur, caret, caret + 1, caret);
974
+ const r = deleteRange(cur, caret, nextGraphemeOffset(cur, caret), caret);
778
975
  state.rawText[name] = r.text;
779
976
  state.caret = r.caret;
780
977
  }
@@ -792,10 +989,11 @@ function handleFormKey(
792
989
  }
793
990
  return { kind: "noop" };
794
991
  }
795
- // Printable insert.
796
- if (key.length === 1 && key >= " " && key <= "~") {
992
+ // Printable insert. Accept exactly one grapheme cluster so CJK, emoji ZWJ
993
+ // sequences, and combining-mark input follow pi-tui Input semantics.
994
+ if (isPrintableGrapheme(key)) {
797
995
  state.rawText[name] = cur.slice(0, caret) + key + cur.slice(caret);
798
- state.caret = caret + 1;
996
+ state.caret = caret + key.length;
799
997
  return { kind: "noop" };
800
998
  }
801
999
  return { kind: "noop" };
@@ -869,19 +1067,41 @@ function handleConfirmKey(
869
1067
  if (key === "y" || key === "Y" || key === "\r" || key === "\n") {
870
1068
  return { kind: "run", values: coerceValues(fields, state.rawText) };
871
1069
  }
872
- if (key === "n" || key === "N" || key === "\x1b") {
1070
+ if (key === "\x03") return { kind: "cancel" };
1071
+ if (key === "n" || key === "N" || matchesKey(key, "escape")) {
873
1072
  state.confirmOpen = false;
874
1073
  return { kind: "noop" };
875
1074
  }
876
1075
  return { kind: "noop" };
877
1076
  }
878
1077
 
1078
+ function isCancelKey(key: string): boolean {
1079
+ return key === "\x03" || matchesKey(key, "escape");
1080
+ }
1081
+
1082
+ function attemptPickerSubmit(
1083
+ state: InputsPickerState,
1084
+ fields: readonly WorkflowInputEntry[],
1085
+ ): InputsPickerAction {
1086
+ const invalid = computeInvalid(fields, state.rawText);
1087
+ if (invalid.length > 0) {
1088
+ state.invalidIndices = invalid;
1089
+ state.focusedIdx = invalid[0]!;
1090
+ state.caret = (state.rawText[fields[state.focusedIdx]!.name] ?? "").length;
1091
+ return { kind: "noop" };
1092
+ }
1093
+ state.invalidIndices = [];
1094
+ state.confirmOpen = true;
1095
+ return { kind: "noop" };
1096
+ }
1097
+
879
1098
  function moveFocus(
880
1099
  state: InputsPickerState,
881
1100
  fields: readonly WorkflowInputEntry[],
882
1101
  delta: number,
883
1102
  ): void {
884
1103
  const n = fields.length;
1104
+ if (n === 0) return;
885
1105
  state.focusedIdx = (state.focusedIdx + delta + n) % n;
886
1106
  const next = fields[state.focusedIdx]!;
887
1107
  state.caret = (state.rawText[next.name] ?? "").length;
@@ -38,7 +38,7 @@ export function computeLayout(
38
38
  opts: LayoutOpts = {},
39
39
  ): LayoutNode[] {
40
40
  const colGap = opts.colGap ?? 4;
41
- const rowGap = opts.rowGap ?? 2;
41
+ const rowGap = opts.rowGap ?? 3;
42
42
 
43
43
  if (stages.length === 0) return [];
44
44