@cspeach/cli 1.1.18 → 1.1.20

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 (252) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/anthropic-provider.js +30 -10
  3. package/dist/agent/cache-keepalive.js +162 -0
  4. package/dist/agent/cold-prune.js +116 -0
  5. package/dist/agent/loop.js +804 -159
  6. package/dist/agent/provider-shape.js +263 -0
  7. package/dist/agent/providers/ai-hub-provider.js +17 -2
  8. package/dist/agent/providers/byok-provider.js +33 -3
  9. package/dist/agent/providers/local-provider.js +12 -2
  10. package/dist/agent/repair-partial.js +125 -7
  11. package/dist/agent/summarise-via-provider.js +6 -1
  12. package/dist/agent/system-prompt.js +38 -0
  13. package/dist/agent/tool-dispatch.js +66 -21
  14. package/dist/agent/tool-loading-pin.js +100 -0
  15. package/dist/agent/turn-error-ux.js +77 -26
  16. package/dist/agent/turn-stream.js +25 -3
  17. package/dist/approvals/adt-type.js +27 -0
  18. package/dist/approvals/advisory-prompt.js +48 -0
  19. package/dist/approvals/advisory-render.js +25 -11
  20. package/dist/approvals/approval-prompt.js +74 -24
  21. package/dist/approvals/jwt.js +2 -1
  22. package/dist/approvals/render.js +64 -36
  23. package/dist/approvals/risk-floor.js +53 -2
  24. package/dist/auth/api-key.js +30 -16
  25. package/dist/auth/keychain.js +0 -0
  26. package/dist/auth/me.js +25 -7
  27. package/dist/cli.js +36 -6
  28. package/dist/commands/auto-compact.js +33 -16
  29. package/dist/commands/compact.js +49 -11
  30. package/dist/commands/config-set.js +16 -3
  31. package/dist/commands/config-show.js +18 -5
  32. package/dist/commands/cost.js +54 -35
  33. package/dist/commands/help.js +88 -50
  34. package/dist/commands/login.js +28 -19
  35. package/dist/commands/logout.js +7 -3
  36. package/dist/commands/plan-audit.js +1 -0
  37. package/dist/commands/plan-chain.js +71 -77
  38. package/dist/commands/plan-continue.js +1 -0
  39. package/dist/commands/plan-resume.js +20 -12
  40. package/dist/commands/whoami.js +23 -9
  41. package/dist/config/loader.js +70 -5
  42. package/dist/cost/cost-log.js +62 -2
  43. package/dist/cost/pricing.js +11 -6
  44. package/dist/doctor/checks/forge-rules.js +13 -3
  45. package/dist/doctor/checks/keychain.js +6 -4
  46. package/dist/doctor/run.js +101 -20
  47. package/dist/index.js +5 -2
  48. package/dist/lib/graceful-exit.js +56 -0
  49. package/dist/lib/piped-prompt.js +65 -0
  50. package/dist/lib/spill-labels.js +13 -0
  51. package/dist/lock-contention.js +2 -2
  52. package/dist/models/resolve.js +93 -2
  53. package/dist/models/server-config.js +158 -3
  54. package/dist/one-shot.js +150 -45
  55. package/dist/projects/answer-blockers.js +6 -1
  56. package/dist/projects/handover-md.js +15 -14
  57. package/dist/projects/image-attachments.js +15 -2
  58. package/dist/projects/plan-run.js +97 -54
  59. package/dist/projects/promote-command.js +11 -2
  60. package/dist/projects/save-command.js +3 -2
  61. package/dist/projects/status.js +2 -1
  62. package/dist/projects/yes-no.js +12 -0
  63. package/dist/renderer/abap-inline.js +18 -14
  64. package/dist/renderer/answer-trim.js +69 -0
  65. package/dist/renderer/banners.js +8 -8
  66. package/dist/renderer/brand-settled.js +19 -0
  67. package/dist/renderer/change-card.js +156 -0
  68. package/dist/renderer/checklist-format.js +130 -0
  69. package/dist/renderer/color-mode.js +45 -0
  70. package/dist/renderer/error-detail.js +104 -0
  71. package/dist/renderer/fence-state.js +46 -0
  72. package/dist/renderer/footer-line.js +100 -0
  73. package/dist/renderer/glyphs.js +25 -0
  74. package/dist/renderer/goodbye.js +38 -0
  75. package/dist/renderer/legacy-palette.js +41 -0
  76. package/dist/renderer/look.js +30 -0
  77. package/dist/renderer/markdown.js +299 -92
  78. package/dist/renderer/notice-log.js +72 -0
  79. package/dist/renderer/notice-shape.js +48 -0
  80. package/dist/renderer/notices.js +40 -8
  81. package/dist/renderer/panel-rows.js +16 -0
  82. package/dist/renderer/pipeline.js +66 -0
  83. package/dist/renderer/progress-chatter.js +23 -17
  84. package/dist/renderer/rendering-mode.js +34 -0
  85. package/dist/renderer/routing-line.js +14 -0
  86. package/dist/renderer/sanitize.js +12 -0
  87. package/dist/renderer/severity.js +2 -7
  88. package/dist/renderer/startup-lines.js +154 -0
  89. package/dist/renderer/status-footer.js +52 -34
  90. package/dist/renderer/steering-echo.js +41 -0
  91. package/dist/renderer/syntax.js +31 -12
  92. package/dist/renderer/tables.js +5 -1
  93. package/dist/renderer/theme.js +44 -0
  94. package/dist/renderer/thinking-heartbeat.js +33 -2
  95. package/dist/renderer/todo-block.js +10 -49
  96. package/dist/renderer/tool-labels.js +269 -0
  97. package/dist/renderer/tool-widget.js +245 -51
  98. package/dist/renderer/trace.js +42 -0
  99. package/dist/renderer/transcript-flow.js +132 -0
  100. package/dist/renderer/tty.js +34 -1
  101. package/dist/renderer/ui-width.js +55 -0
  102. package/dist/renderer/verify-chain.js +4 -2
  103. package/dist/renderer/widget-fallback.js +58 -65
  104. package/dist/repl/bracketed-paste.js +7 -1
  105. package/dist/repl/builtin-commands.js +19 -8
  106. package/dist/repl/credential-handover-gate.js +28 -0
  107. package/dist/repl/current-transport.js +13 -0
  108. package/dist/repl/early-line-buffer.js +5 -2
  109. package/dist/repl/file-picker.js +54 -12
  110. package/dist/repl/ink-stdin-guard.js +66 -0
  111. package/dist/repl/inquirer-guard.js +59 -16
  112. package/dist/repl/inquirer-theme.js +27 -33
  113. package/dist/repl/paste-marker.js +19 -0
  114. package/dist/repl/plan-turn-end.js +20 -0
  115. package/dist/repl/post-turn-status.js +43 -11
  116. package/dist/repl/reroute-turn.js +21 -0
  117. package/dist/repl/reset-tty-stdin.js +44 -0
  118. package/dist/repl/restore-guard.js +22 -0
  119. package/dist/repl/resume-standalone.js +63 -0
  120. package/dist/repl/rule8-detector.js +11 -3
  121. package/dist/repl/safety-confirm.js +161 -93
  122. package/dist/repl/session-spend-line.js +8 -4
  123. package/dist/repl/themed-prompts.js +19 -0
  124. package/dist/repl/ui-look-command.js +75 -0
  125. package/dist/repl/update-method-preview-hook.js +48 -5
  126. package/dist/repl.js +863 -339
  127. package/dist/rewind/cli.js +3 -2
  128. package/dist/rewind/format.js +14 -9
  129. package/dist/rewind/restore.js +11 -0
  130. package/dist/router/classifier.js +47 -4
  131. package/dist/router/intent-extractor.js +24 -8
  132. package/dist/router/piped-routing.js +23 -0
  133. package/dist/router/resume-routing.js +16 -0
  134. package/dist/router/routing-failure.js +57 -0
  135. package/dist/sap/first-run-choice.js +1 -1
  136. package/dist/sap/onboarding.js +3 -2
  137. package/dist/sap/standalone-onboarding.js +1 -1
  138. package/dist/sap/system-info.js +4 -2
  139. package/dist/sap/unreachable.js +28 -0
  140. package/dist/sap-errors/clean-error-text.js +182 -0
  141. package/dist/session/interrupt-reason.js +39 -0
  142. package/dist/session/recap.js +36 -24
  143. package/dist/session/repin-model.js +18 -0
  144. package/dist/session/resume.js +104 -36
  145. package/dist/session/store.js +84 -28
  146. package/dist/session/user-prompt.js +21 -0
  147. package/dist/skill-catalog.js +7 -0
  148. package/dist/skills/bundled-skills.js +76 -69
  149. package/dist/skills/preamble.js +75 -0
  150. package/dist/skills/source-manifest.js +11 -1
  151. package/dist/standards/standards-init.js +1 -1
  152. package/dist/test-helpers/answer-any.js +46 -0
  153. package/dist/tools/approval.js +27 -11
  154. package/dist/tools/ask-question.js +47 -5
  155. package/dist/tools/filesystem/file-read.js +11 -1
  156. package/dist/tools/filesystem/file-write.js +19 -2
  157. package/dist/tools/fiori/fe-extend.js +16 -1
  158. package/dist/tools/fiori/fe-scaffold.js +105 -3
  159. package/dist/tools/fiori/html-escapes.js +28 -0
  160. package/dist/tools/fiori/metadata/audit.js +605 -0
  161. package/dist/tools/fiori/metadata/references.js +104 -0
  162. package/dist/tools/fiori/metadata/types.js +8 -0
  163. package/dist/tools/fiori/preview/app-guard.js +215 -0
  164. package/dist/tools/fiori/preview/env.js +193 -0
  165. package/dist/tools/fiori/preview/readiness.js +127 -0
  166. package/dist/tools/fiori/preview/registry.js +412 -0
  167. package/dist/tools/fiori/preview/start.js +469 -0
  168. package/dist/tools/fiori/samples/loader.js +20 -5
  169. package/dist/tools/fiori/scaffold.js +18 -6
  170. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  171. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  172. package/dist/tools/fiori/smoke/assertions.js +161 -24
  173. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  174. package/dist/tools/fiori/smoke/batch.js +273 -0
  175. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  176. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  177. package/dist/tools/fiori/smoke/driver.js +171 -4
  178. package/dist/tools/fiori/smoke/evidence.js +35 -0
  179. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  180. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  181. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  182. package/dist/tools/fiori/tools.js +651 -7
  183. package/dist/tools/fiori/ui5-version.js +16 -0
  184. package/dist/tools/index.js +8 -0
  185. package/dist/tools/local-build.js +6 -0
  186. package/dist/tools/result-spill.js +238 -0
  187. package/dist/tools/sap-read.js +74 -15
  188. package/dist/tools/sap-write.js +122 -10
  189. package/dist/tools/shell/shell_exec.js +39 -12
  190. package/dist/tools/subagent/adt-serial.js +33 -0
  191. package/dist/tools/subagent/agent_run.js +2 -0
  192. package/dist/tools/subagent/read_agent.js +178 -0
  193. package/dist/tools/subagent/reader-prompt.js +48 -0
  194. package/dist/tools/syntax-state.js +67 -0
  195. package/dist/tools/todo.js +26 -19
  196. package/dist/tools/tool-loading.js +255 -0
  197. package/dist/tools/tool-output-read.js +117 -0
  198. package/dist/tools/transport-fit.js +462 -0
  199. package/dist/tools/transport-resolution.js +3 -1
  200. package/dist/tools/transport.js +39 -8
  201. package/dist/tools/verify.js +184 -8
  202. package/dist/ui/app.js +284 -146
  203. package/dist/ui/approval-modal.js +41 -15
  204. package/dist/ui/ask-answer-text.js +17 -0
  205. package/dist/ui/body.js +25 -6
  206. package/dist/ui/card-slot.js +98 -0
  207. package/dist/ui/checklist.js +9 -0
  208. package/dist/ui/coaching-picker-classic.js +8 -5
  209. package/dist/ui/command-palette.js +6 -4
  210. package/dist/ui/confirm-request.js +18 -0
  211. package/dist/ui/context-grid.js +2 -1
  212. package/dist/ui/file-palette.js +6 -4
  213. package/dist/ui/files-card-emitter.js +19 -0
  214. package/dist/ui/footer-line.js +16 -0
  215. package/dist/ui/footer.js +105 -104
  216. package/dist/ui/input-wrap.js +122 -0
  217. package/dist/ui/interrupt.js +103 -0
  218. package/dist/ui/key-burst.js +117 -0
  219. package/dist/ui/line-resolution.js +4 -3
  220. package/dist/ui/live-area.js +71 -0
  221. package/dist/ui/login-banner.js +54 -43
  222. package/dist/ui/rewind-panel.js +76 -24
  223. package/dist/ui/role-props.js +47 -0
  224. package/dist/ui/sap-state-store.js +2 -1
  225. package/dist/ui/session-timeline.js +49 -3
  226. package/dist/ui/skill-picker.js +5 -47
  227. package/dist/ui/text-input.js +128 -24
  228. package/dist/ui/todo-emitter.js +19 -0
  229. package/dist/ui/turn-status-emitter.js +65 -2
  230. package/dist/ui/turn-status.js +16 -19
  231. package/dist/ui/use-card-request.js +44 -0
  232. package/dist/ui/widgets/ask-card.js +521 -0
  233. package/dist/ui/widgets/bar-chart.js +11 -5
  234. package/dist/ui/widgets/coaching-picker.js +50 -55
  235. package/dist/ui/widgets/confirm-card.js +193 -0
  236. package/dist/ui/widgets/dep-graph.js +3 -2
  237. package/dist/ui/widgets/diff-viewer.js +6 -5
  238. package/dist/ui/widgets/files-card.js +97 -0
  239. package/dist/ui/widgets/question-card.js +2 -1
  240. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  241. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  242. package/dist/ui/widgets/stack-frames.js +2 -1
  243. package/dist/upgrade-check.js +39 -8
  244. package/package.json +12 -2
  245. package/test-harness/fe-draft/app.ts +188 -0
  246. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  247. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  248. package/test-harness/fe-draft/routes.ts +224 -0
  249. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  250. package/test-harness/fe-draft/server.ts +225 -0
  251. package/test-harness/fe-draft/variants.ts +196 -0
  252. package/vitest.smoke-e2e.config.ts +20 -0
@@ -1,50 +1,8 @@
1
- import { select } from '@inquirer/prompts';
2
1
  /**
3
- * Sentinel value returned when the user cancels the picker (Esc / Ctrl+C or
4
- * selects the "↩ cancel" entry). Callers re-prompt rather than dispatching.
5
- */
6
- export const PICK_CANCELLED = Symbol('PICK_CANCELLED');
7
- /**
8
- * Arrow-key picker for the top-3 classifier candidates. Returns the chosen
9
- * skill name. Each choice shows the skill, a trimmed reasoning line, and
10
- * the confidence percentage.
2
+ * Sentinel value returned when the user cancels a skill picker (Esc / Ctrl+C
3
+ * or the Cancel entry). Callers re-prompt rather than dispatching.
11
4
  *
12
- * Cancel routes:
13
- * - Selecting the "↩ cancel" item returns PICK_CANCELLED
14
- * - Pressing Ctrl+C inquirer raises ExitPromptError → we catch and treat as cancel
15
- * (without Ctrl+C'ing the whole process)
5
+ * The old inquirer `pickSkill` that lived here had no callers and was removed
6
+ * in piece 2 (it rendered a confidence percentage, which §3 drops).
16
7
  */
17
- export async function pickSkill(result) {
18
- const candidates = [result.skill, ...result.alternates].slice(0, 3);
19
- const reasoning = (result.reasoning ?? '').slice(0, 60);
20
- const confidencePct = Math.round(result.confidence * 100);
21
- const choices = candidates.map((skill, idx) => {
22
- // Top-1 carries the reasoning + confidence; alternates are simple names.
23
- if (idx === 0) {
24
- const suffix = reasoning
25
- ? ` — ${reasoning} (${confidencePct}%)`
26
- : ` (${confidencePct}%)`;
27
- return { name: `/${skill}${suffix}`, value: skill };
28
- }
29
- return { name: `/${skill}`, value: skill };
30
- });
31
- // Cancel entry — user can arrow-down past the candidates and pick this to
32
- // go back to the REPL prompt and retype the request.
33
- choices.push({ name: '↩ cancel (go back to prompt)', value: '__cancel__' });
34
- try {
35
- const chosen = await select({
36
- message: 'Pick a skill — or ↩ to cancel:',
37
- choices,
38
- });
39
- if (chosen === '__cancel__')
40
- return PICK_CANCELLED;
41
- return chosen;
42
- }
43
- catch (err) {
44
- // inquirer throws ExitPromptError on Ctrl+C. Treat as cancel, not a crash.
45
- const name = err?.name;
46
- if (name === 'ExitPromptError')
47
- return PICK_CANCELLED;
48
- throw err;
49
- }
50
- }
8
+ export const PICK_CANCELLED = Symbol('PICK_CANCELLED');
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  /**
3
3
  * PasteSafeTextInput — drop-in replacement for `ink-text-input` (D29,
4
4
  * "chewed words" during seed-session paste; 2026-06-12).
@@ -23,12 +23,50 @@ import { jsx as _jsx } from "react/jsx-runtime";
23
23
  * Rendering, cursor drawing, placeholder, and mask behaviour are vendored
24
24
  * verbatim from ink-text-input v6 (MIT, © Vadim Demedes) minus
25
25
  * `highlightPastedText` (Footer never used it).
26
+ *
27
+ * Piece 2 (I1, spec §7) — `wrapWidth` switches rendering to the input box:
28
+ * the value is HARD-WRAPPED here (ui/input-wrap.ts: string-width cells,
29
+ * graphemes whole, tabs → 2 spaces) and shown as rows that grow to `maxLines`
30
+ * then scroll with the cursor row visible. ↑/↓ move between rows; the first /
31
+ * last row hand over to history (onHistoryUp / onHistoryDown). A paste of more
32
+ * than PASTE_FOLD_LINES lines becomes one atomic chip via onLargePaste.
33
+ * Without `wrapWidth` everything renders exactly as before (Ctrl+R search,
34
+ * question cards).
26
35
  */
27
36
  import { useState, useEffect, useRef } from 'react';
28
- import { Text, useInput } from 'ink';
37
+ import { Box, Text, useInput } from 'ink';
29
38
  import chalk from 'chalk';
30
- /** An image chip ending exactly at the cursor — Backspace removes it whole. */
31
- const CHIP_BEFORE_CURSOR = /\[Image #\d+\]$/;
39
+ import stringWidth from 'string-width';
40
+ import { wrapInput, visibleRange, indexAt, pastedLineCount, INPUT_MAX_LINES, PASTE_FOLD_LINES, } from './input-wrap.js';
41
+ import { isNoColor } from '../renderer/color-mode.js';
42
+ import { paint } from '../renderer/theme.js';
43
+ import { applyKeyBurst, stripControlChars } from './key-burst.js';
44
+ /** An atomic chip ending exactly at the cursor — Backspace removes it whole. */
45
+ const CHIP_BEFORE_CURSOR = /\[(?:Image #\d+|Pasted \d+ lines(?: #\d+)?)\]$/;
46
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
47
+ /**
48
+ * Draw the fake cursor at cell `col` of a wrapped display row. After the last
49
+ * character it takes one cell. NO_COLOR has no inverse video: the end cursor
50
+ * shows as '_' and a mid-row cursor is not marked.
51
+ */
52
+ export function drawCursor(line, col) {
53
+ let acc = 0;
54
+ let out = '';
55
+ let placed = false;
56
+ for (const { segment } of SEGMENTER.segment(line)) {
57
+ if (!placed && acc === col) {
58
+ out += isNoColor() ? segment : chalk.inverse(segment);
59
+ placed = true;
60
+ }
61
+ else {
62
+ out += segment;
63
+ }
64
+ acc += stringWidth(segment);
65
+ }
66
+ if (!placed)
67
+ out += isNoColor() ? '_' : chalk.inverse(' ');
68
+ return out;
69
+ }
32
70
  /**
33
71
  * Pure windowing core (exported for tests). Given the value length, the
34
72
  * cursor position, the display budget (character cells) and whether the
@@ -50,7 +88,7 @@ export function computeDisplayWindow(valueLength, cursorOffset, budget, cursorVi
50
88
  const end = Math.min(valueLength, start + budget);
51
89
  return { start, end };
52
90
  }
53
- export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, onPasteImage, }) {
91
+ export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, onPasteImage, wrapWidth, maxLines, heldLines, prompt, onHistoryUp, onHistoryDown, onLargePaste, }) {
54
92
  const [cursorOffset, setCursorOffset] = useState((originalValue || '').length);
55
93
  // The paste-safety core: synchronous mirrors of value + cursor. The
56
94
  // input handler reads/writes THESE, never the (possibly stale) props.
@@ -89,6 +127,10 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
89
127
  setCursorOffset((previous) => previous > originalValue.length ? originalValue.length : previous);
90
128
  }, [originalValue, focus, showCursor]);
91
129
  const value = mask ? mask.repeat(originalValue.length) : originalValue;
130
+ const moveCursor = (i) => {
131
+ cursorRef.current = i;
132
+ setCursorOffset(i);
133
+ };
92
134
  // Wave1 live-fix (Bug 1) — display-only horizontal window + control-char
93
135
  // flattening. The mirrors, the props, and every handler above operate on
94
136
  // the FULL value; only what's painted is windowed, so the row can never
@@ -124,11 +166,27 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
124
166
  }
125
167
  }
126
168
  useInput((input, key) => {
127
- if (key.upArrow ||
128
- key.downArrow ||
129
- (key.ctrl && input === 'c') ||
130
- key.tab ||
131
- (key.shift && key.tab)) {
169
+ if ((key.ctrl && input === 'c') || key.tab || (key.shift && key.tab))
170
+ return;
171
+ if (key.upArrow || key.downArrow) {
172
+ // Without wrapWidth: ignored, exactly as before (other consumers).
173
+ if (wrapWidth === undefined)
174
+ return;
175
+ // I1 — move between visual rows; the edges hand over to history.
176
+ const cur = valueRef.current;
177
+ const w = wrapInput(cur, cursorRef.current, wrapWidth);
178
+ if (key.upArrow) {
179
+ if (w.cursorLine > 0)
180
+ moveCursor(indexAt(cur, wrapWidth, w.cursorLine - 1, w.cursorCol));
181
+ else
182
+ onHistoryUp?.();
183
+ }
184
+ else if (w.cursorLine < w.lines.length - 1) {
185
+ moveCursor(indexAt(cur, wrapWidth, w.cursorLine + 1, w.cursorCol));
186
+ }
187
+ else {
188
+ onHistoryDown?.();
189
+ }
132
190
  return;
133
191
  }
134
192
  if (onPasteImage && input === 'v' && (key.ctrl || key.meta)) {
@@ -161,7 +219,18 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
161
219
  // Task 7 — complete lines carved out of a newline-bearing paste
162
220
  // chunk; reported to the parent AFTER the mirrors are updated.
163
221
  let pasteLines = null;
164
- if (key.leftArrow) {
222
+ // Fix round 3 (item 6) — several control keys coalesced into one chunk
223
+ // (held Backspace / arrows) match no single key; apply each in order.
224
+ // Pastes carrying newlines keep their own branch below.
225
+ const burst = !key.backspace && !key.delete && !key.leftArrow && !key.rightArrow
226
+ && !input.includes('\n') && !input.includes('\r')
227
+ ? applyKeyBurst(currentValue, cursorRef.current, input)
228
+ : null;
229
+ if (burst) {
230
+ nextValue = burst.value;
231
+ nextCursorOffset = burst.cursor;
232
+ }
233
+ else if (key.leftArrow) {
165
234
  if (showCursor)
166
235
  nextCursorOffset--;
167
236
  }
@@ -170,7 +239,7 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
170
239
  nextCursorOffset++;
171
240
  }
172
241
  else if (key.backspace || key.delete) {
173
- const chip = onPasteImage
242
+ const chip = (onPasteImage || onLargePaste)
174
243
  ? CHIP_BEFORE_CURSOR.exec(currentValue.slice(0, cursorRef.current))
175
244
  : null;
176
245
  if (chip) {
@@ -197,16 +266,28 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
197
266
  // compounds on the fragment — duplicate delivery of a completed
198
267
  // line is structurally impossible, and every D29 mirror/echo
199
268
  // mechanism below operates exactly as for a plain insert.
200
- const cleanInput = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
201
- const combined = currentValue.slice(0, cursorRef.current) +
202
- cleanInput +
203
- currentValue.slice(cursorRef.current, currentValue.length);
204
- const lastNl = combined.lastIndexOf('\n');
205
- pasteLines = combined.slice(0, lastNl).split('\n');
206
- nextValue = combined.slice(lastNl + 1);
207
- // Cursor lands where the insertion ended, mapped into the fragment
208
- // (0 when the paste ended on the newline itself).
209
- nextCursorOffset = Math.max(0, cursorRef.current + cleanInput.length - (lastNl + 1));
269
+ const cleanInput = stripControlChars(input.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
270
+ if (onLargePaste && pastedLineCount(cleanInput) > PASTE_FOLD_LINES) {
271
+ // I1 — a long paste folds to ONE atomic `[Pasted N lines]` chip at the
272
+ // cursor; the Footer keeps the text and expands it on submit. The
273
+ // mirror stays single-line, so the D29 invariants hold unchanged.
274
+ // The text is kept exactly as pasted (a trailing newline included), so
275
+ // what is typed after the chip still starts on a new line when sent.
276
+ const chip = onLargePaste(cleanInput);
277
+ nextValue = currentValue.slice(0, cursorRef.current) + chip + currentValue.slice(cursorRef.current);
278
+ nextCursorOffset = cursorRef.current + chip.length;
279
+ }
280
+ else {
281
+ const combined = currentValue.slice(0, cursorRef.current) +
282
+ cleanInput +
283
+ currentValue.slice(cursorRef.current, currentValue.length);
284
+ const lastNl = combined.lastIndexOf('\n');
285
+ pasteLines = combined.slice(0, lastNl).split('\n');
286
+ nextValue = combined.slice(lastNl + 1);
287
+ // Cursor lands where the insertion ended, mapped into the fragment
288
+ // (0 when the paste ended on the newline itself).
289
+ nextCursorOffset = Math.max(0, cursorRef.current + cleanInput.length - (lastNl + 1));
290
+ }
210
291
  }
211
292
  else {
212
293
  // 2026-09-17 — a single-line field (question form, question modal:
@@ -217,9 +298,9 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
217
298
  // stray control characters. Join the lines with one space instead:
218
299
  // the whole paste is visible and the answer is clean. Fields that do
219
300
  // handle lines (the main prompt) never take this branch.
220
- const insert = (input.includes('\n') || input.includes('\r'))
301
+ const insert = stripControlChars((input.includes('\n') || input.includes('\r'))
221
302
  ? input.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n+/g, ' ')
222
- : input;
303
+ : input);
223
304
  nextValue =
224
305
  currentValue.slice(0, cursorRef.current) +
225
306
  insert +
@@ -244,6 +325,29 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
244
325
  onChange(nextValue);
245
326
  }
246
327
  }, { isActive: focus });
328
+ if (wrapWidth !== undefined) {
329
+ // I1 — the input box body: held rows (dim) + the value's rows, windowed to
330
+ // maxLines with the cursor row visible. Rows already fit (wrapInput), and
331
+ // truncate-end guarantees no dynamic-frame row can soft-wrap.
332
+ const heldRows = (heldLines ?? []).flatMap((l) => wrapInput(l, -1, wrapWidth).lines);
333
+ const w = wrapInput(value, cursorRendered ? cursorOffset : -1, wrapWidth);
334
+ const all = [...heldRows, ...w.lines];
335
+ const cursorRow = cursorRendered ? heldRows.length + w.cursorLine : all.length - 1;
336
+ const { start, end } = visibleRange(all.length, cursorRow, maxLines ?? INPUT_MAX_LINES);
337
+ const lead = prompt ?? '';
338
+ const leadWidth = Math.max(1, stringWidth(lead));
339
+ const indent = ' '.repeat(stringWidth(lead));
340
+ return (_jsx(Box, { flexDirection: "column", children: all.slice(start, end).map((row, k) => {
341
+ const r = start + k;
342
+ const prefix = k > 0
343
+ ? indent
344
+ : start === 0 ? chalk.bold(paint('sap', lead)) : paint('dim', '…'.padEnd(leadWidth));
345
+ const body = r < heldRows.length
346
+ ? paint('dim', row)
347
+ : cursorRendered && r === cursorRow ? drawCursor(row, w.cursorCol) : row;
348
+ return _jsxs(Text, { wrap: "truncate-end", children: [prefix, body] }, r);
349
+ }) }));
350
+ }
247
351
  return (_jsx(Text, { children: placeholder
248
352
  ? value.length > 0
249
353
  ? renderedValue
@@ -23,3 +23,22 @@ import { EventEmitter } from 'node:events';
23
23
  * <App> for stdout and corrupts the Ink frame.
24
24
  */
25
25
  export const todoEmitter = new EventEmitter();
26
+ /*
27
+ * Piece 2 (spec §4) — second event: todoEmitter.emit('close') at turn end
28
+ * when the plan is finished; <App> hides the live checklist until the next
29
+ * 'update'. Emitted only by repl/plan-turn-end.ts.
30
+ */
31
+ /**
32
+ * "The plan changed this turn" — set by a TOP-LEVEL todo_set (ctx carries
33
+ * todoEmitter; subagent ctxs do not), consumed once by emitPlanTurnEnd so a
34
+ * turn that never touched the plan leaves no close line.
35
+ */
36
+ let todoSetThisTurn = false;
37
+ export function markTodoSetThisTurn() {
38
+ todoSetThisTurn = true;
39
+ }
40
+ export function consumeTodoSetThisTurn() {
41
+ const v = todoSetThisTurn;
42
+ todoSetThisTurn = false;
43
+ return v;
44
+ }
@@ -4,7 +4,7 @@
4
4
  // the CC-style "always alive while working" indicator, B5 smoke
5
5
  // feedback round 3: fresh-line heartbeats were not enough).
6
6
  //
7
- // Same singleton-emitter pattern as error-emitter / approval-emitter:
7
+ // Same singleton-emitter pattern as approval-emitter / ask-question-emitter:
8
8
  // the REPL marks turn start/stop around runTurn (try/finally so a
9
9
  // thrown turn can never leave the row stuck), loop.ts narrates the
10
10
  // current activity, and <TurnStatus /> renders it in the dynamic Ink
@@ -12,7 +12,7 @@
12
12
  // listeners, and every call is a no-op.
13
13
  import { EventEmitter } from 'node:events';
14
14
  class TurnStatusEmitter extends EventEmitter {
15
- state = { active: false, reserved: false, paused: false, label: '', startedAt: 0 };
15
+ state = { active: false, reserved: false, paused: false, routing: false, routingStartedAt: 0, label: '', startedAt: 0 };
16
16
  /**
17
17
  * BUG-3 (2026-07-05 live smoke) — the row vanished WHILE the model still
18
18
  * worked. Root cause was the twin-turn overlap (BUG-2): turn 1 finished
@@ -25,6 +25,35 @@ class TurnStatusEmitter extends EventEmitter {
25
25
  * of blanking the liveness row.
26
26
  */
27
27
  depth = 0;
28
+ /**
29
+ * UI fix round (CLI 1.1.20) — the turn CLOCK pauses while the user is the
30
+ * one being waited on. Live: a question card waited ~7 min and the receipt
31
+ * read "420.2s". Every blocking prompt already pauses this emitter (card
32
+ * slot withFocusClaim, the loop's interactive-tool pause, the save hook),
33
+ * so the emitter keeps the paused time: `pausedTotal` is a process-wide
34
+ * monotonic sum of closed pause intervals, `pauseBegan` the open one.
35
+ * Receipts snapshot pausedMs() at turn start and subtract the growth.
36
+ */
37
+ pausedTotal = 0;
38
+ pauseBegan = null;
39
+ /** pausedMs() when the current startedAt was set (the live row's base). */
40
+ pauseBase = 0;
41
+ closePause(now = Date.now()) {
42
+ if (this.pauseBegan === null)
43
+ return;
44
+ this.pausedTotal += Math.max(0, now - this.pauseBegan);
45
+ this.pauseBegan = null;
46
+ }
47
+ /** Total ms spent paused on user input so far (monotonic, includes an open pause). */
48
+ pausedMs(now = Date.now()) {
49
+ return this.pausedTotal + (this.pauseBegan === null ? 0 : Math.max(0, now - this.pauseBegan));
50
+ }
51
+ /** The live row's clock: time since the turn started, less time paused on the user. */
52
+ activeElapsedMs(now = Date.now()) {
53
+ if (!this.state.active)
54
+ return 0;
55
+ return Math.max(0, now - this.state.startedAt - (this.pausedMs(now) - this.pauseBase));
56
+ }
28
57
  /**
29
58
  * BUG-2b — mark a dispatch in-flight before the async pre-turn work runs.
30
59
  * Invisible (no spinner); isBusy() reports it so the submit guard can
@@ -36,6 +65,20 @@ class TurnStatusEmitter extends EventEmitter {
36
65
  this.state = { ...this.state, reserved: true };
37
66
  this.emit('change', this.state);
38
67
  }
68
+ /** Fix round 3 (item 8) — the classifier round-trip started. */
69
+ routingStart() {
70
+ if (this.state.routing)
71
+ return;
72
+ this.state = { ...this.state, routing: true, routingStartedAt: Date.now() };
73
+ this.emit('change', this.state);
74
+ }
75
+ /** Fix round 3 (item 8) — routing finished (any outcome). */
76
+ routingEnd() {
77
+ if (!this.state.routing)
78
+ return;
79
+ this.state = { ...this.state, routing: false, routingStartedAt: 0 };
80
+ this.emit('change', this.state);
81
+ }
39
82
  /** BUG-2b — release the pre-turn reservation (dispatch lifecycle ended). */
40
83
  release() {
41
84
  if (!this.state.reserved)
@@ -48,11 +91,25 @@ class TurnStatusEmitter extends EventEmitter {
48
91
  isBusy() {
49
92
  return this.state.active || this.state.reserved;
50
93
  }
94
+ /**
95
+ * Counts top-level turns (depth 0 → 1). card-slot's focus claim keys its
96
+ * refcount on this so a claim left unsettled by an earlier turn (Ctrl+C
97
+ * mid-card) cannot suppress, or fake, a later turn's resume.
98
+ */
99
+ turnCount = 0;
100
+ /** Identifies the current top-level turn; nested start() keeps it. */
101
+ turnGeneration() {
102
+ return this.turnCount;
103
+ }
51
104
  start(label = 'thinking') {
52
105
  // BUG-3 — nest instead of replace so an overlapping stop() can't blank
53
106
  // the row (see the `depth` field doc). `...this.state` preserves the
54
107
  // reserved flag set by reserve() at dispatch start.
108
+ if (this.depth === 0)
109
+ this.turnCount++;
55
110
  this.depth++;
111
+ this.closePause();
112
+ this.pauseBase = this.pausedMs();
56
113
  this.state = { ...this.state, active: true, paused: false, label, startedAt: Date.now() };
57
114
  this.emit('change', this.state);
58
115
  }
@@ -61,6 +118,7 @@ class TurnStatusEmitter extends EventEmitter {
61
118
  activity(label) {
62
119
  if (!this.state.active)
63
120
  return;
121
+ this.closePause();
64
122
  this.state = { ...this.state, label, paused: false };
65
123
  this.emit('change', this.state);
66
124
  }
@@ -68,6 +126,7 @@ class TurnStatusEmitter extends EventEmitter {
68
126
  pause() {
69
127
  if (!this.state.active || this.state.paused)
70
128
  return;
129
+ this.pauseBegan = Date.now();
71
130
  this.state = { ...this.state, paused: true };
72
131
  this.emit('change', this.state);
73
132
  }
@@ -75,6 +134,7 @@ class TurnStatusEmitter extends EventEmitter {
75
134
  resume() {
76
135
  if (!this.state.active || !this.state.paused)
77
136
  return;
137
+ this.closePause();
78
138
  this.state = { ...this.state, paused: false };
79
139
  this.emit('change', this.state);
80
140
  }
@@ -88,6 +148,9 @@ class TurnStatusEmitter extends EventEmitter {
88
148
  this.depth--;
89
149
  if (this.depth > 0)
90
150
  return;
151
+ // A turn ended while a card waited (Ctrl+C / Esc on it): the wait was
152
+ // still the user's time, not the turn's.
153
+ this.closePause();
91
154
  this.state = { ...this.state, active: false, paused: false, label: '', startedAt: 0 };
92
155
  this.emit('change', this.state);
93
156
  }
@@ -1,33 +1,24 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Text } from 'ink';
4
4
  import { turnStatusEmitter } from './turn-status-emitter.js';
5
+ import { roleProps } from './role-props.js';
5
6
  const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
7
  const TICK_MS = 250;
7
- const PEACH = '#FFCBA4';
8
8
  function fmtElapsed(ms) {
9
- const s = Math.floor(ms / 1000);
9
+ // `now` is sampled at mount / on each tick, so the first frame after a turn
10
+ // starts can precede startedAt — never show "-1s".
11
+ const s = Math.max(0, Math.floor(ms / 1000));
10
12
  if (s < 60)
11
13
  return `${s}s`;
12
14
  return `${Math.floor(s / 60)}m ${s % 60}s`;
13
15
  }
14
- /**
15
- * Task 5 (ux-wave2) — cap for the in-progress-todo strip appended to the
16
- * status row. The row already carries spinner + label + elapsed + hint;
17
- * 40 chars of todo text keeps the whole line comfortably inside a normal
18
- * terminal (and wrap="truncate-end" clips the rest regardless).
19
- */
20
- export const TODO_STRIP_MAX = 40;
21
- /** ≤40 chars, '…' as the 40th when clipped. Exported for tests. */
22
- export function truncateTodoStrip(text) {
23
- return text.length > TODO_STRIP_MAX ? `${text.slice(0, TODO_STRIP_MAX - 1)}…` : text;
24
- }
25
16
  /**
26
17
  * Persistent turn-status row (2026-06-07) — lives in the DYNAMIC Ink
27
18
  * frame just above the Footer, so it repaints with the frame and
28
19
  * vanishes the instant the turn ends. While a turn is in flight:
29
20
  *
30
- * ⠹ sap_create_object · 34s · ctrl+c to interrupt
21
+ * ⠹ Reading ZCL_ORDER_API · 34s · ctrl+c to stop
31
22
  *
32
23
  * The label follows the loop's narration (thinking / tool name /
33
24
  * writing response / save check). Its presence answers "is it still
@@ -38,7 +29,7 @@ export function truncateTodoStrip(text) {
38
29
  * cursor #25 class of bug came from a raw-stdout spinner; this
39
30
  * component cannot reproduce it). Guard: ink-cursor-invariant test.
40
31
  */
41
- export function TurnStatus({ inProgressTodo } = {}) {
32
+ export function TurnStatus() {
42
33
  const [state, setState] = useState(turnStatusEmitter.snapshot());
43
34
  const [frame, setFrame] = useState(0);
44
35
  const [now, setNow] = useState(Date.now());
@@ -56,19 +47,25 @@ export function TurnStatus({ inProgressTodo } = {}) {
56
47
  // repaints the dynamic frame every 250ms and overdraws an inquirer
57
48
  // approval picker / churns the Ink ask_question modal (stacked borders,
58
49
  // lost keypress). The row resumes the instant the user answers.
59
- if (!state.active || state.paused)
50
+ const routingOnly = state.routing && !state.active;
51
+ if ((!state.active || state.paused) && !routingOnly)
60
52
  return;
61
53
  const interval = setInterval(() => {
62
54
  setFrame((f) => (f + 1) % FRAMES.length);
63
55
  setNow(Date.now());
64
56
  }, TICK_MS);
65
57
  return () => clearInterval(interval);
66
- }, [state.active, state.paused]);
58
+ }, [state.active, state.paused, state.routing]);
59
+ // Fix round 3 (item 8) — routing (no turn yet): spinner + "routing…" +
60
+ // elapsed, no "ctrl+c to stop" (there is no turn to stop).
61
+ if (state.routing && !state.active) {
62
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { ...roleProps('sap'), children: [FRAMES[frame], " "] }), _jsx(Text, { children: "routing\u2026" }), _jsxs(Text, { ...roleProps('dim'), children: [" \u00B7 ", fmtElapsed(now - state.routingStartedAt)] })] }));
63
+ }
67
64
  if (!state.active || state.paused)
68
65
  return null;
69
66
  // wrap="truncate-end" (wave1 live-fix, Bug 1): this row sits in the
70
67
  // dynamic frame too — a long tool label must clip, never soft-wrap
71
68
  // (wrapped dynamic-frame rows break Ink's erase accounting and leak
72
69
  // stale rows into scrollback).
73
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: PEACH, children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", fmtElapsed(now - state.startedAt), " \u00B7 ctrl+c to interrupt"] }), inProgressTodo ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsxs(Text, { color: PEACH, children: ["\u25B6 ", truncateTodoStrip(inProgressTodo)] })] })) : null] }));
70
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { ...roleProps('sap'), children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { ...roleProps('dim'), children: [" \u00B7 ", fmtElapsed(turnStatusEmitter.activeElapsedMs(now)), " \u00B7 ctrl+c to stop"] })] }));
74
71
  }
@@ -0,0 +1,44 @@
1
+ // src/ui/use-card-request.ts
2
+ //
3
+ // The one "a request arrives → mount its card → resolve → clear" hook for the
4
+ // card slot (piece 2 §1.6). Same rules the ask/approval listeners in app.tsx
5
+ // learned the hard way:
6
+ // - a replacing request resolves the earlier one CANCELLED (no leaked promise);
7
+ // - `finish` clears only if the request still owns the slot;
8
+ // - unmount resolves an open request CANCELLED so a turn never wedges.
9
+ import { useEffect, useRef, useState } from 'react';
10
+ export function useCardRequest(subscribe, cancelled, onArrive) {
11
+ const [pending, setPending] = useState(null);
12
+ const [seq, setSeq] = useState(0);
13
+ const ref = useRef(null);
14
+ const arriveRef = useRef(onArrive);
15
+ arriveRef.current = onArrive;
16
+ useEffect(() => {
17
+ const unsubscribe = subscribe((req) => {
18
+ const prev = ref.current;
19
+ if (prev)
20
+ prev.resolve(cancelled);
21
+ ref.current = req;
22
+ setSeq((s) => s + 1);
23
+ arriveRef.current?.();
24
+ setPending(req);
25
+ });
26
+ return () => {
27
+ unsubscribe();
28
+ const open = ref.current;
29
+ ref.current = null;
30
+ if (open)
31
+ open.resolve(cancelled);
32
+ };
33
+ // Mount-once by design: subscribe/cancelled are stable for the App's life.
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, []);
36
+ const finish = (req, result) => {
37
+ req.resolve(result);
38
+ if (ref.current === req) {
39
+ ref.current = null;
40
+ setPending(null);
41
+ }
42
+ };
43
+ return { pending, seq, finish };
44
+ }