@bastani/atomic 0.8.4 → 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 +6 -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
@@ -25,7 +25,7 @@
25
25
  * ╰──────────────────────────────────────────────────────────────────╯
26
26
  * integer · optional · loop count
27
27
  *
28
- * ╭ EDIT ╮ tab next · shift+tab prev · ctrl+s run · esc cancel
28
+ * ╭ EDIT ╮ tab next · shift+tab prev · ctrl+enter run · esc cancel
29
29
  * │ EDIT │
30
30
  * ╰──────╯
31
31
  *
@@ -55,6 +55,55 @@ export interface InlineCardOpts {
55
55
  theme: GraphTheme;
56
56
  }
57
57
 
58
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
59
+
60
+ function graphemes(text: string): string[] {
61
+ return Array.from(graphemeSegmenter.segment(text), (s) => s.segment);
62
+ }
63
+
64
+ function clampGraphemeOffset(text: string, caret: number): number {
65
+ const c = Math.max(0, Math.min(caret, text.length));
66
+ if (c === text.length) return c;
67
+ for (const s of graphemeSegmenter.segment(text)) {
68
+ if (s.index === c) return c;
69
+ if (s.index > c) break;
70
+ }
71
+ let prev = 0;
72
+ for (const s of graphemeSegmenter.segment(text)) {
73
+ if (s.index >= c) break;
74
+ prev = s.index;
75
+ }
76
+ return prev;
77
+ }
78
+
79
+ function headToWidth(text: string, width: number): string {
80
+ if (width <= 0) return "";
81
+ let out = "";
82
+ let used = 0;
83
+ for (const g of graphemes(text)) {
84
+ const w = visibleWidth(g);
85
+ if (used + w > width) break;
86
+ out += g;
87
+ used += w;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function tailToWidth(text: string, width: number): string {
93
+ if (width <= 0) return "";
94
+ let out = "";
95
+ let used = 0;
96
+ const gs = graphemes(text);
97
+ for (let i = gs.length - 1; i >= 0; i--) {
98
+ const g = gs[i]!;
99
+ const w = visibleWidth(g);
100
+ if (used + w > width) break;
101
+ out = g + out;
102
+ used += w;
103
+ }
104
+ return out;
105
+ }
106
+
58
107
  // ---------------------------------------------------------------------------
59
108
  // Public renderer
60
109
  // ---------------------------------------------------------------------------
@@ -110,7 +159,8 @@ function renderHeaderBand(state: InlineFormState, theme: GraphTheme, width: numb
110
159
  );
111
160
 
112
161
  const nameVisible = ` ${state.workflowName}`;
113
- const counter = `${state.focusedIdx + 1} / ${state.fields.length}`;
162
+ const focusTargetCount = state.fields.length;
163
+ const counter = `${Math.min(state.focusedIdx + 1, focusTargetCount)} / ${focusTargetCount}`;
114
164
  const counterVisible = counter;
115
165
 
116
166
  const leftEdgePad = 1;
@@ -130,8 +180,6 @@ function renderHeaderBand(state: InlineFormState, theme: GraphTheme, width: numb
130
180
 
131
181
  function renderFooterBand(theme: GraphTheme, width: number): string[] {
132
182
  const chromeBg = hexBg(theme.backgroundPanel);
133
- const text = hexToAnsi(theme.text);
134
- const muted = hexToAnsi(theme.textMuted);
135
183
  const dim = hexToAnsi(theme.dim);
136
184
 
137
185
  const { top, mid, bot, visibleWidth: pillW } = renderOutlinePill(
@@ -140,11 +188,13 @@ function renderFooterBand(theme: GraphTheme, width: number): string[] {
140
188
  chromeBg,
141
189
  );
142
190
 
191
+ const text = hexToAnsi(theme.text);
192
+ const muted = hexToAnsi(theme.textMuted);
143
193
  const hints: Array<{ key: string; label: string }> = [
144
- { key: "tab", label: "next" },
145
- { key: "shift+tab", label: "prev" },
146
- { key: "ctrl+s", label: "run" },
147
- { key: "esc", label: "cancel" },
194
+ { key: "tab", label: "Next" },
195
+ { key: "shift+tab", label: "Prev" },
196
+ { key: "ctrl+enter", label: "Run" },
197
+ { key: "esc", label: "Cancel" },
148
198
  ];
149
199
  const sep = `${chromeBg} ${dim}·${RESET}${chromeBg} `;
150
200
  const segments = hints.map(
@@ -280,15 +330,7 @@ function renderFieldContent(
280
330
  return [paint(field.placeholder ?? "", theme.dim)];
281
331
  }
282
332
  if (focused) {
283
- const c = Math.max(0, Math.min(caret ?? raw.length, raw.length));
284
- const before = raw.slice(0, c);
285
- const after = raw.slice(c);
286
- return [
287
- clip(
288
- paint(before, theme.text) + paint("▋", theme.accent) + paint(after, theme.text),
289
- usable,
290
- ),
291
- ];
333
+ return [renderCaretLine(raw, caret ?? raw.length, usable, theme, theme.text)];
292
334
  }
293
335
  return [clip(paint(raw, theme.textMuted), usable)];
294
336
  }
@@ -309,12 +351,30 @@ function renderFieldContent(
309
351
  if (row !== layout.cursorRow) {
310
352
  return paint(line, theme.text);
311
353
  }
312
- const before = line.slice(0, layout.cursorCol);
313
- const after = line.slice(layout.cursorCol);
314
- return paint(before, theme.text) + paint("▋", theme.accent) + paint(after, theme.text);
354
+ return renderCaretLine(line, layout.cursorOffset ?? line.length, usable, theme, theme.text);
315
355
  });
316
356
  }
317
357
 
358
+ function renderCaretLine(
359
+ raw: string,
360
+ caret: number,
361
+ usable: number,
362
+ theme: GraphTheme,
363
+ color: string,
364
+ ): string {
365
+ const safe = clampGraphemeOffset(raw, caret);
366
+ const beforeFull = raw.slice(0, safe);
367
+ const afterFull = raw.slice(safe);
368
+ const cursorWidth = 1;
369
+ let before = beforeFull;
370
+ let after = afterFull;
371
+ if (visibleWidth(beforeFull) + cursorWidth + visibleWidth(afterFull) > usable) {
372
+ before = tailToWidth(beforeFull, Math.max(0, usable - cursorWidth));
373
+ after = headToWidth(afterFull, Math.max(0, usable - visibleWidth(before) - cursorWidth));
374
+ }
375
+ return clip(paint(before, color) + paint("▋", theme.accent) + paint(after, color), usable);
376
+ }
377
+
318
378
  // ---------------------------------------------------------------------------
319
379
  // Frozen states
320
380
  // ---------------------------------------------------------------------------
@@ -382,40 +442,63 @@ export function layoutTextField(
382
442
  raw: string,
383
443
  usable: number,
384
444
  caret: number,
385
- ): { lines: string[]; cursorRow: number; cursorCol: number } {
445
+ ): { lines: string[]; cursorRow: number; cursorCol: number; cursorOffset?: number } {
386
446
  const width = Math.max(1, Math.floor(usable));
387
- const safeCaret = Math.max(0, Math.min(caret, raw.length));
447
+ const safeCaret = clampGraphemeOffset(raw, caret);
388
448
  const visualLines: string[] = [];
449
+ const lineStarts: number[] = [];
450
+ const lineEnds: number[] = [];
389
451
  let curLine = "";
390
- let cursorRow = 0;
391
- let cursorCol = 0;
392
- let cursorRecorded = false;
393
- const recordCursorIfMatched = (offset: number): void => {
394
- if (offset === safeCaret && !cursorRecorded) {
395
- cursorRow = visualLines.length;
396
- cursorCol = curLine.length;
397
- cursorRecorded = true;
398
- }
452
+ let curWidth = 0;
453
+ let lineStart = 0;
454
+
455
+ const pushLine = (end: number): void => {
456
+ visualLines.push(curLine);
457
+ lineStarts.push(lineStart);
458
+ lineEnds.push(end);
459
+ curLine = "";
460
+ curWidth = 0;
461
+ lineStart = end;
399
462
  };
400
- for (let i = 0; i < raw.length; i++) {
401
- recordCursorIfMatched(i);
402
- const ch = raw[i]!;
403
- if (ch === "\n") {
404
- visualLines.push(curLine);
405
- curLine = "";
463
+
464
+ for (const s of graphemeSegmenter.segment(raw)) {
465
+ const offset = s.index;
466
+ const g = s.segment;
467
+ if (g === "\n") {
468
+ pushLine(offset);
469
+ lineStart = offset + g.length;
406
470
  continue;
407
471
  }
408
- curLine += ch;
409
- if (curLine.length >= width) {
410
- visualLines.push(curLine);
411
- curLine = "";
472
+ const w = visibleWidth(g);
473
+ if (curLine !== "" && curWidth + w > width) {
474
+ pushLine(offset);
475
+ }
476
+ curLine += g;
477
+ curWidth += w;
478
+ if (curWidth >= width) {
479
+ pushLine(offset + g.length);
412
480
  }
413
481
  }
414
- recordCursorIfMatched(raw.length);
415
482
  visualLines.push(curLine);
416
- if (!cursorRecorded) {
417
- cursorRow = visualLines.length - 1;
418
- cursorCol = curLine.length;
483
+ lineStarts.push(lineStart);
484
+ lineEnds.push(raw.length);
485
+
486
+ let cursorRow = visualLines.length - 1;
487
+ for (let i = 0; i < visualLines.length; i++) {
488
+ const start = lineStarts[i]!;
489
+ const end = lineEnds[i]!;
490
+ const nextStart = lineStarts[i + 1];
491
+ if (safeCaret >= start && safeCaret < end) {
492
+ cursorRow = i;
493
+ break;
494
+ }
495
+ if (safeCaret === end) {
496
+ cursorRow = nextStart === safeCaret ? i + 1 : i;
497
+ }
419
498
  }
420
- return { lines: visualLines, cursorRow, cursorCol };
499
+ cursorRow = Math.max(0, Math.min(cursorRow, visualLines.length - 1));
500
+ const line = visualLines[cursorRow] ?? "";
501
+ const cursorOffset = Math.max(0, Math.min(safeCaret - (lineStarts[cursorRow] ?? 0), line.length));
502
+ const cursorCol = visibleWidth(line.slice(0, cursorOffset));
503
+ return { lines: visualLines, cursorRow, cursorCol, cursorOffset };
421
504
  }
@@ -14,16 +14,16 @@
14
14
  * ctrl+u — delete to logical line start
15
15
  * ctrl+k — delete to logical line end
16
16
  * space — boolean toggle
17
- * enter — newline (text) | submit (others move to next field)
17
+ * enter — newline (text) | otherwise next field
18
18
  * printable ASCII — insert at caret (text/string/number)
19
- * ctrl+s — submit form (if valid)
20
- * esc — cancel form
19
+ * ctrl+enter — submit form (if valid)
20
+ * esc / ctrl+c — cancel form
21
21
  *
22
22
  * Editor-mode keys (cursor movement, word jumps, deletions) route through
23
23
  * the Pi `KeybindingsManager` injected by the host at factory time, so any
24
24
  * user-configured keybinding overrides surfaces here as well. Form-level
25
- * keys (tab/shift+tab/esc/ctrl+s) stay as raw byte checks because they are
26
- * workflow form contract, not Pi-configurable actions.
25
+ * keys (tab/shift+tab/ctrl+enter/esc/ctrl+c) stay as raw byte checks because
26
+ * they are workflow form contract, not Pi-configurable actions.
27
27
  *
28
28
  * On submit/cancel the editor calls back to the orchestrator which:
29
29
  * 1. Marks the form state finalized (renderer flips to frozen view)
@@ -57,13 +57,14 @@ import {
57
57
  wordLeft,
58
58
  wordRight,
59
59
  } from "./keybindings-adapter.js";
60
+ import { matchesKey, visibleWidth } from "./text-helpers.js";
60
61
 
61
62
  export type FormEditorOutcome = "submit" | "cancel";
62
63
 
63
64
  export interface InlineFormEditorOpts {
64
65
  formId: string;
65
66
  theme: GraphTheme;
66
- /** Called when ctrl+s passes validation or esc fires. Triggers cleanup. */
67
+ /** Called when Ctrl+Enter passes validation or cancel fires. */
67
68
  onExit: (outcome: FormEditorOutcome) => void;
68
69
  /**
69
70
  * Pi's `KeybindingsManager` injected as the third arg of the editor
@@ -75,24 +76,80 @@ export interface InlineFormEditorOpts {
75
76
  keybindings?: KeybindingsLike;
76
77
  }
77
78
 
79
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
80
+
81
+ function graphemes(text: string): string[] {
82
+ return Array.from(graphemeSegmenter.segment(text), (s) => s.segment);
83
+ }
84
+
85
+ function previousGraphemeOffset(text: string, caret: number): number {
86
+ const c = Math.max(0, Math.min(caret, text.length));
87
+ let prev = 0;
88
+ for (const s of graphemeSegmenter.segment(text)) {
89
+ if (s.index >= c) break;
90
+ prev = s.index;
91
+ }
92
+ return prev;
93
+ }
94
+
95
+ function nextGraphemeOffset(text: string, caret: number): number {
96
+ const c = Math.max(0, Math.min(caret, text.length));
97
+ for (const s of graphemeSegmenter.segment(text)) {
98
+ if (s.index >= c) return Math.min(text.length, s.index + s.segment.length);
99
+ if (s.index + s.segment.length > c) return s.index + s.segment.length;
100
+ }
101
+ return text.length;
102
+ }
103
+
104
+ function clampGraphemeOffset(text: string, caret: number): number {
105
+ const c = Math.max(0, Math.min(caret, text.length));
106
+ if (c === text.length) return c;
107
+ for (const s of graphemeSegmenter.segment(text)) {
108
+ if (s.index === c) return c;
109
+ if (s.index > c) break;
110
+ }
111
+ return previousGraphemeOffset(text, c);
112
+ }
113
+
114
+ function visualColumn(text: string, caret: number): number {
115
+ return visibleWidth(text.slice(0, clampGraphemeOffset(text, caret)));
116
+ }
117
+
118
+ function offsetAtVisualColumn(text: string, targetCol: number): number {
119
+ let col = 0;
120
+ for (const s of graphemeSegmenter.segment(text)) {
121
+ const w = visibleWidth(s.segment);
122
+ if (col + w > targetCol) return s.index;
123
+ col += w;
124
+ }
125
+ return text.length;
126
+ }
127
+
128
+ function isPrintableGrapheme(data: string): boolean {
129
+ if (data.length === 0 || data.includes("\x1b")) return false;
130
+ for (const ch of data) {
131
+ const code = ch.codePointAt(0);
132
+ if (code === undefined || code < 0x20 || code === 0x7f) return false;
133
+ }
134
+ return graphemes(data).length === 1;
135
+ }
136
+
78
137
  /**
79
138
  * Move the caret one logical line up inside a multi-line text field.
80
139
  * Returns the new caret offset, or `null` when the caret is already on
81
140
  * the first logical line — that's the boundary signal the caller uses to
82
- * fall through to focus-prev. The "column" is preserved across lines as
83
- * a byte offset within the current logical line, clamped to the target
84
- * line's length (same behaviour as Pi's own editor `preferredVisualCol`
85
- * but at the logical-line level, since the form caret is a single
86
- * integer offset rather than `{row, col}`).
141
+ * fall through to focus-prev. The visual cell column is preserved across
142
+ * lines, matching pi-tui Editor behaviour for CJK/emoji-width text.
87
143
  */
88
144
  function caretLineUp(raw: string, caret: number): number | null {
89
- const lineStart = raw.lastIndexOf("\n", caret - 1) + 1;
145
+ const safe = clampGraphemeOffset(raw, caret);
146
+ const lineStart = raw.lastIndexOf("\n", safe - 1) + 1;
90
147
  if (lineStart === 0) return null; // first logical line — boundary
91
148
  const prevLineEnd = lineStart - 1;
92
149
  const prevLineStart = raw.lastIndexOf("\n", prevLineEnd - 1) + 1;
93
- const colInLine = caret - lineStart;
94
- const prevLineLen = prevLineEnd - prevLineStart;
95
- return prevLineStart + Math.min(colInLine, prevLineLen);
150
+ const colInLine = visualColumn(raw.slice(lineStart, safe), safe - lineStart);
151
+ const prevLine = raw.slice(prevLineStart, prevLineEnd);
152
+ return prevLineStart + offsetAtVisualColumn(prevLine, colInLine);
96
153
  }
97
154
 
98
155
  /**
@@ -101,15 +158,16 @@ function caretLineUp(raw: string, caret: number): number | null {
101
158
  * the last logical line.
102
159
  */
103
160
  function caretLineDown(raw: string, caret: number): number | null {
104
- const nextNl = raw.indexOf("\n", caret);
161
+ const safe = clampGraphemeOffset(raw, caret);
162
+ const nextNl = raw.indexOf("\n", safe);
105
163
  if (nextNl === -1) return null; // last logical line — boundary
106
- const lineStart = raw.lastIndexOf("\n", caret - 1) + 1;
107
- const colInLine = caret - lineStart;
164
+ const lineStart = raw.lastIndexOf("\n", safe - 1) + 1;
165
+ const colInLine = visualColumn(raw.slice(lineStart, safe), safe - lineStart);
108
166
  const nextLineStart = nextNl + 1;
109
167
  const nextNlAfter = raw.indexOf("\n", nextLineStart);
110
168
  const nextLineEnd = nextNlAfter === -1 ? raw.length : nextNlAfter;
111
- const nextLineLen = nextLineEnd - nextLineStart;
112
- return nextLineStart + Math.min(colInLine, nextLineLen);
169
+ const nextLine = raw.slice(nextLineStart, nextLineEnd);
170
+ return nextLineStart + offsetAtVisualColumn(nextLine, colInLine);
113
171
  }
114
172
 
115
173
  // ── Bracketed paste handling ─────────────────────────────────────────────
@@ -307,7 +365,7 @@ export class InlineFormEditor implements PiEditorComponent {
307
365
  // Fallback for hosts without bracketed paste: a multi-character
308
366
  // chunk of printable text (no escape bytes) is treated as paste.
309
367
  // Single-char input still flows through the routeKey path so the
310
- // existing keystroke handlers (arrows, ctrl+s, etc.) keep working.
368
+ // existing keystroke handlers (arrows, paste, etc.) keep working.
311
369
  if (data.length > 1 && isPrintableTextChunk(data)) {
312
370
  if (this.applyPaste(data, state)) {
313
371
  touch(state);
@@ -373,23 +431,25 @@ export class InlineFormEditor implements PiEditorComponent {
373
431
  private routeKey(data: string, state: InlineFormState): boolean {
374
432
  // Globals first. Workflow form contract — these are NOT Pi-configurable
375
433
  // editor actions, so they stay as raw byte checks:
376
- // esc (\x1b) — cancel form
377
- // ctrl+s (\x13) submit form
378
- // tab (\t) focus next field
379
- // shift+tab (\x1b[Z) — focus previous field
380
- if (data === "\x1b") {
434
+ // esc (\x1b) — cancel form
435
+ // ctrl+c (\x03) cancel form
436
+ // ctrl+enter submit form
437
+ // tab (\t) — focus next field
438
+ // shift+tab (\x1b[Z) focus previous field
439
+ if (data === "\x03" || matchesKey(data, "escape")) {
381
440
  this.opts.onExit("cancel");
382
441
  return true;
383
442
  }
384
- if (data === "\x13") {
443
+ if (matchesKey(data, "ctrl+enter")) {
385
444
  if (this.allValid(state)) this.opts.onExit("submit");
445
+ else this.focusFirstInvalid(state);
386
446
  return true;
387
447
  }
388
- if (data === "\t") {
448
+ if (matchesKey(data, "tab")) {
389
449
  this.moveFocus(state, +1);
390
450
  return true;
391
451
  }
392
- if (data === "\x1b[Z") {
452
+ if (matchesKey(data, "shift+tab")) {
393
453
  this.moveFocus(state, -1);
394
454
  return true;
395
455
  }
@@ -520,11 +580,11 @@ export class InlineFormEditor implements PiEditorComponent {
520
580
 
521
581
  // Character cursor movement.
522
582
  if (matchesAction(this.kb, data, "tui.editor.cursorLeft")) {
523
- state.caret = Math.max(0, caret - 1);
583
+ state.caret = previousGraphemeOffset(cur, caret);
524
584
  return true;
525
585
  }
526
586
  if (matchesAction(this.kb, data, "tui.editor.cursorRight")) {
527
- state.caret = Math.min(cur.length, caret + 1);
587
+ state.caret = nextGraphemeOffset(cur, caret);
528
588
  return true;
529
589
  }
530
590
 
@@ -562,7 +622,7 @@ export class InlineFormEditor implements PiEditorComponent {
562
622
  }
563
623
  if (matchesAction(this.kb, data, "tui.editor.deleteCharBackward")) {
564
624
  if (caret > 0) {
565
- const r = deleteRange(cur, caret - 1, caret, caret);
625
+ const r = deleteRange(cur, previousGraphemeOffset(cur, caret), caret, caret);
566
626
  state.rawText[name] = r.text;
567
627
  state.caret = r.caret;
568
628
  }
@@ -570,7 +630,7 @@ export class InlineFormEditor implements PiEditorComponent {
570
630
  }
571
631
  if (matchesAction(this.kb, data, "tui.editor.deleteCharForward")) {
572
632
  if (caret < cur.length) {
573
- const r = deleteRange(cur, caret, caret + 1, caret);
633
+ const r = deleteRange(cur, caret, nextGraphemeOffset(cur, caret), caret);
574
634
  state.rawText[name] = r.text;
575
635
  state.caret = r.caret;
576
636
  }
@@ -594,12 +654,12 @@ export class InlineFormEditor implements PiEditorComponent {
594
654
  return true;
595
655
  }
596
656
 
597
- // Printable ASCII insertion — no Pi action, raw byte check. Numeric
657
+ // Printable insertion — no Pi action, raw grapheme check. Numeric
598
658
  // fields accept the same printable range as text; per-field validation
599
659
  // catches non-numeric content at submit time.
600
- if (data.length === 1 && data >= " " && data <= "~") {
660
+ if (isPrintableGrapheme(data)) {
601
661
  state.rawText[name] = cur.slice(0, caret) + data + cur.slice(caret);
602
- state.caret = caret + 1;
662
+ state.caret = caret + data.length;
603
663
  return true;
604
664
  }
605
665
  return false;
@@ -607,11 +667,26 @@ export class InlineFormEditor implements PiEditorComponent {
607
667
 
608
668
  private moveFocus(state: InlineFormState, delta: number): void {
609
669
  const n = state.fields.length;
670
+ if (n === 0) return;
610
671
  state.focusedIdx = (state.focusedIdx + delta + n) % n;
611
672
  const next = state.fields[state.focusedIdx]!;
612
673
  state.caret = (state.rawText[next.name] ?? "").length;
613
674
  }
614
675
 
676
+ private focusFirstInvalid(state: InlineFormState): void {
677
+ const idx = state.fields.findIndex((f) => {
678
+ const v = state.rawText[f.name] ?? "";
679
+ if (f.required && v.trim() === "") return true;
680
+ if ((f.type === "number" || f.type === "integer") && v !== "" && !Number.isFinite(Number(v))) {
681
+ return true;
682
+ }
683
+ return f.type === "select" && Boolean(f.choices) && v !== "" && !f.choices!.includes(v);
684
+ });
685
+ if (idx < 0) return;
686
+ state.focusedIdx = idx;
687
+ state.caret = (state.rawText[state.fields[idx]!.name] ?? "").length;
688
+ }
689
+
615
690
  private allValid(state: InlineFormState): boolean {
616
691
  for (const f of state.fields) {
617
692
  const v = state.rawText[f.name] ?? "";