@cspeach/cli 1.1.17 → 1.1.19

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 (225) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +80 -22
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/skills/signing-public-key.js +1 -1
  132. package/dist/standards/standards-init.js +1 -1
  133. package/dist/test-helpers/answer-any.js +46 -0
  134. package/dist/tools/approval.js +27 -11
  135. package/dist/tools/ask-question.js +47 -5
  136. package/dist/tools/filesystem/file-write.js +19 -2
  137. package/dist/tools/fiori/fe-extend.js +16 -1
  138. package/dist/tools/fiori/fe-scaffold.js +105 -3
  139. package/dist/tools/fiori/html-escapes.js +28 -0
  140. package/dist/tools/fiori/metadata/audit.js +605 -0
  141. package/dist/tools/fiori/metadata/references.js +104 -0
  142. package/dist/tools/fiori/metadata/types.js +8 -0
  143. package/dist/tools/fiori/preview/app-guard.js +215 -0
  144. package/dist/tools/fiori/preview/env.js +193 -0
  145. package/dist/tools/fiori/preview/readiness.js +127 -0
  146. package/dist/tools/fiori/preview/registry.js +412 -0
  147. package/dist/tools/fiori/preview/start.js +469 -0
  148. package/dist/tools/fiori/samples/loader.js +20 -5
  149. package/dist/tools/fiori/scaffold.js +18 -6
  150. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  151. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  152. package/dist/tools/fiori/smoke/assertions.js +161 -24
  153. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  154. package/dist/tools/fiori/smoke/batch.js +273 -0
  155. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  156. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  157. package/dist/tools/fiori/smoke/driver.js +171 -4
  158. package/dist/tools/fiori/smoke/evidence.js +35 -0
  159. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  160. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  161. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  162. package/dist/tools/fiori/tools.js +651 -7
  163. package/dist/tools/fiori/ui5-version.js +16 -0
  164. package/dist/tools/index.js +8 -0
  165. package/dist/tools/local-build.js +6 -0
  166. package/dist/tools/sap-read.js +16 -1
  167. package/dist/tools/sap-write.js +122 -10
  168. package/dist/tools/shell/shell_exec.js +30 -12
  169. package/dist/tools/syntax-state.js +67 -0
  170. package/dist/tools/todo.js +23 -18
  171. package/dist/tools/transport-fit.js +462 -0
  172. package/dist/tools/transport-resolution.js +3 -1
  173. package/dist/tools/transport.js +33 -7
  174. package/dist/tools/verify.js +184 -8
  175. package/dist/ui/app.js +284 -146
  176. package/dist/ui/approval-modal.js +41 -15
  177. package/dist/ui/ask-answer-text.js +17 -0
  178. package/dist/ui/body.js +25 -6
  179. package/dist/ui/card-slot.js +98 -0
  180. package/dist/ui/checklist.js +9 -0
  181. package/dist/ui/coaching-picker-classic.js +8 -5
  182. package/dist/ui/command-palette.js +6 -4
  183. package/dist/ui/confirm-request.js +18 -0
  184. package/dist/ui/context-grid.js +2 -1
  185. package/dist/ui/file-palette.js +6 -4
  186. package/dist/ui/files-card-emitter.js +19 -0
  187. package/dist/ui/footer-line.js +16 -0
  188. package/dist/ui/footer.js +105 -104
  189. package/dist/ui/input-wrap.js +122 -0
  190. package/dist/ui/interrupt.js +103 -0
  191. package/dist/ui/key-burst.js +117 -0
  192. package/dist/ui/line-resolution.js +4 -3
  193. package/dist/ui/live-area.js +71 -0
  194. package/dist/ui/login-banner.js +54 -43
  195. package/dist/ui/rewind-panel.js +76 -24
  196. package/dist/ui/role-props.js +47 -0
  197. package/dist/ui/sap-state-store.js +1 -0
  198. package/dist/ui/session-timeline.js +49 -3
  199. package/dist/ui/skill-picker.js +5 -47
  200. package/dist/ui/text-input.js +128 -24
  201. package/dist/ui/todo-emitter.js +19 -0
  202. package/dist/ui/turn-status-emitter.js +28 -2
  203. package/dist/ui/turn-status.js +16 -19
  204. package/dist/ui/use-card-request.js +44 -0
  205. package/dist/ui/widgets/ask-card.js +521 -0
  206. package/dist/ui/widgets/bar-chart.js +11 -5
  207. package/dist/ui/widgets/coaching-picker.js +50 -55
  208. package/dist/ui/widgets/confirm-card.js +193 -0
  209. package/dist/ui/widgets/dep-graph.js +3 -2
  210. package/dist/ui/widgets/diff-viewer.js +6 -5
  211. package/dist/ui/widgets/files-card.js +97 -0
  212. package/dist/ui/widgets/question-card.js +2 -1
  213. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  214. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  215. package/dist/ui/widgets/stack-frames.js +2 -1
  216. package/dist/upgrade-check.js +39 -8
  217. package/package.json +11 -2
  218. package/test-harness/fe-draft/app.ts +188 -0
  219. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  220. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  221. package/test-harness/fe-draft/routes.ts +224 -0
  222. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  223. package/test-harness/fe-draft/server.ts +225 -0
  224. package/test-harness/fe-draft/variants.ts +196 -0
  225. package/vitest.smoke-e2e.config.ts +20 -0
package/dist/ui/footer.js CHANGED
@@ -2,44 +2,44 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useState, useEffect, useCallback, useRef } from 'react';
3
3
  import { Box, Text, useInput, useStdout } from 'ink';
4
4
  // D29 paste fix (2026-06-12): vendored paste-safe replacement for
5
- // ink-text-input — multi-chunk pastes were losing every chunk but the
6
- // last (stale-prop closure in the upstream component). See text-input.tsx.
5
+ // ink-text-input. See text-input.tsx.
7
6
  import TextInput from './text-input.js';
8
7
  import { pasteClipboardImage } from './clipboard-image.js';
9
8
  import Fuse from 'fuse.js';
10
- // Task 8 (ux-wave1) — persistence lives in the shared writer (one file,
11
- // one dedup guard for the Footer, Ink handleSubmit, and classic readline).
12
- // It resolves os.homedir() lazily so tests can point it at a temp dir.
9
+ // Task 8 (ux-wave1) — persistence lives in the shared writer.
13
10
  import { appendHistoryEntry, readHistoryEntries, flattenHistoryEntry } from '../repl/history.js';
11
+ import { uiWidth, truncateToWidth } from '../renderer/ui-width.js';
12
+ import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
13
+ import { getGlyphs } from '../renderer/glyphs.js';
14
+ import { roleProps, selectedProps } from './role-props.js';
15
+ import { countInputRows, expandPasteChips, nextPasteChip, INPUT_MAX_LINES } from './input-wrap.js';
16
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
14
17
  function loadHistory() {
15
18
  return readHistoryEntries();
16
19
  }
17
20
  function appendHistory(line, history) {
18
- // The shared writer flattens (newlines/tabs/C0 controls → ' '), trims,
19
- // and skips consecutive duplicates (bash HISTCONTROL=ignoredups / CC
20
- // parity). Mirror into the in-memory array ONLY when the entry was
21
- // actually written, so arrow-up and Ctrl+R stay consistent with the
22
- // file. Same flatten as the writer (tab-flatten fast-follow) so the
23
- // mirror never diverges from the persisted entry.
21
+ // The shared writer flattens, trims and skips consecutive duplicates;
22
+ // mirror into memory only when the entry was written (↑ / Ctrl+R stay
23
+ // consistent with the file).
24
24
  const entry = flattenHistoryEntry(line);
25
25
  if (appendHistoryEntry(entry))
26
26
  history.push(entry);
27
27
  }
28
- const PEACH = '#FFCBA4';
29
- /** The input prompt — its width feeds the display budget below. */
30
- const PROMPT = 'CSPeach › ';
31
- /** Held multi-line draft: how many of the newest lines are shown above the input. */
32
- const HELD_LINES_SHOWN = 3;
33
- export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange }) {
28
+ /** I1 (spec §7) — the prompt glyph; the brand stays on the start screen. */
29
+ export const PROMPT_GLYPH = '›';
30
+ const PROMPT = `${PROMPT_GLYPH} `;
31
+ /** Rounded border (2) + paddingX 1 on each side (2). */
32
+ const BOX_CHROME = 4;
33
+ /**
34
+ * Width of the input box (and of the footer line under it). uiWidth() caps it
35
+ * at 100; the −1 keeps the box off the exact-terminal-width deferred-wrap edge
36
+ * on Windows Terminal (the margin the old single-row budget kept, Bug 1).
37
+ */
38
+ export function inputBoxWidth(columns) {
39
+ return Math.max(12, Math.min(uiWidth(columns), columns - 1));
40
+ }
41
+ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange, maxLines, onInputLinesChange, waitingForCard, }) {
34
42
  const [value, setValue] = useState('');
35
- // Wave1 live-fix (Bug 1, statusline duplication) — the input row must
36
- // NEVER soft-wrap in the terminal: Ink's log-update erases logical
37
- // lines, not terminal rows, so one wrapped dynamic-frame row leaks the
38
- // StatusLine into scrollback on every keystroke repaint (see the
39
- // maxDisplayWidth doc in text-input.tsx for the ink source refs). Cap
40
- // the displayed value at columns − prompt − 1 (the −1 keeps the row off
41
- // the exact-width deferred-wrap edge on Windows Terminal). Re-rendered
42
- // on stdout resize so a narrowed window tightens the budget promptly.
43
43
  const { stdout } = useStdout();
44
44
  const [, bumpOnResize] = useState(0);
45
45
  useEffect(() => {
@@ -50,46 +50,40 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
50
50
  return () => { stdout.off('resize', onResize); };
51
51
  }, [stdout]);
52
52
  const columns = stdout?.columns || 80;
53
- const inputBudget = Math.max(8, columns - PROMPT.length - 1);
53
+ const boxWidth = inputBoxWidth(columns);
54
+ const wrapWidth = Math.max(4, boxWidth - BOX_CHROME - PROMPT.length);
54
55
  const [history] = useState(loadHistory);
55
56
  const [histIdx, setHistIdx] = useState(-1);
56
57
  const [fuzzyMode, setFuzzyMode] = useState(false);
57
58
  const [fuzzyQuery, setFuzzyQuery] = useState('');
58
59
  const [fuzzyResults, setFuzzyResults] = useState([]);
59
- // Task 7 (ux-wave1) — multiline continuation buffer. Holds COMPLETE
60
- // lines (from trailing-`\` submits and multi-line pastes) until the
61
- // final Enter joins them with the current input line into ONE message.
62
- // The TextInput never sees this state — it stays single-line.
63
- //
64
- // bufferRef is the synchronous mirror of the same D29 shape as
65
- // text-input's valueRef: a paste tail and its Enter can land in ONE
66
- // stdin drain, so onPasteLines and handleSubmit may both run before
67
- // React commits the state update. All reads/writes go through the ref;
68
- // the state exists only to drive the indicator render.
60
+ const [fuzzyIdx, setFuzzyIdx] = useState(0);
61
+ // Task 7 (ux-wave1) — continuation buffer (complete lines from trailing-`\`
62
+ // submits and short multi-line pastes). bufferRef is the synchronous mirror
63
+ // (a paste tail and its Enter can land in ONE stdin drain).
69
64
  const [buffer, setBuffer] = useState([]);
70
65
  const bufferRef = useRef([]);
71
66
  const updateBuffer = useCallback((lines) => {
72
67
  bufferRef.current = lines;
73
68
  setBuffer(lines);
74
69
  }, []);
70
+ // I1 — folded long pastes: chip text → pasted text, expanded on submit.
71
+ const pastesRef = useRef(new Map());
75
72
  const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
76
- // Mirror state into parent when it cares (e.g. App's ? key-binding gate).
77
73
  useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
78
- // Task 7 — mirror the buffer depth into App (live-trigger suppression).
79
74
  useEffect(() => { onBufferChange?.(buffer.length); }, [buffer.length, onBufferChange]);
80
- // Task 7 — complete lines carved out of a multi-line paste by the
81
- // TextInput (see onPasteLines in text-input.tsx). Append, never submit.
75
+ const desiredRows = countInputRows(buffer, value, wrapWidth);
76
+ useEffect(() => { onInputLinesChange?.(desiredRows); }, [desiredRows, onInputLinesChange]);
82
77
  const handlePasteLines = useCallback((lines) => {
83
78
  updateBuffer([...bufferRef.current, ...lines]);
84
79
  }, [updateBuffer]);
85
- // If the parent pushes a prefill (palette pick, palette cancel restore,
86
- // empty-body guard), pre-fill our input. Keyed on the nonce so identical
87
- // text can re-fire. Empty text is honoured too (Task 5): App's live
88
- // key-triggers (`/`, `?`, Alt+M) push an empty prefill to SWALLOW the
89
- // trigger character, which Ink also delivers to our TextInput — without
90
- // the clear, the leaked char reappears once the overlay closes. Nonce 0
91
- // is the untouched initial state (App never pushes nonce 0), so skip it
92
- // rather than clearing on mount.
80
+ const handleLargePaste = useCallback((text) => {
81
+ const chip = nextPasteChip(text, pastesRef.current);
82
+ pastesRef.current.set(chip, text);
83
+ return chip;
84
+ }, []);
85
+ // Prefill (palette pick / cancel restore / live-trigger swallow). Nonce 0 is
86
+ // the untouched initial state — skip it rather than clearing on mount.
93
87
  useEffect(() => {
94
88
  if (prefill && prefill.nonce > 0)
95
89
  setValue(prefill.text);
@@ -98,13 +92,30 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
98
92
  useEffect(() => {
99
93
  if (fuzzyMode) {
100
94
  setFuzzyResults(fuse.search(fuzzyQuery).slice(0, 8).map((r) => r.item));
95
+ setFuzzyIdx(0);
101
96
  }
102
97
  }, [fuzzyQuery, fuzzyMode, fuse]);
98
+ // I1 — history recall, called by the TextInput at the first / last visual row.
99
+ const historyUp = useCallback(() => {
100
+ const next = Math.min(histIdx + 1, history.length - 1);
101
+ setHistIdx(next);
102
+ setValue(history[history.length - 1 - next] ?? '');
103
+ }, [histIdx, history]);
104
+ const historyDown = useCallback(() => {
105
+ // Not browsing history: ↓ on the last row leaves the draft alone.
106
+ if (histIdx === -1)
107
+ return;
108
+ const next = Math.max(histIdx - 1, -1);
109
+ setHistIdx(next);
110
+ setValue(next === -1 ? '' : history[history.length - 1 - next] ?? '');
111
+ }, [histIdx, history]);
103
112
  useInput((input, key) => {
104
113
  if (key.ctrl && input === 'r') {
105
- onHistoryTrigger();
114
+ // I1 — Ctrl+R is prompt-history search (spec §8 shortcuts); the write
115
+ // history stays on /history.
106
116
  setFuzzyMode(true);
107
117
  setFuzzyQuery('');
118
+ setFuzzyIdx(0);
108
119
  return;
109
120
  }
110
121
  if (fuzzyMode) {
@@ -112,77 +123,65 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
112
123
  setFuzzyMode(false);
113
124
  return;
114
125
  }
115
- if (key.return && fuzzyResults[0]) {
116
- // TODO (v0.4): cursor-aware selection for Ctrl+R palette.
117
- // Current implementation always selects the top result, matching
118
- // bash/zsh default reverse-i-search behaviour — users who want a
119
- // different result re-type to refine the query.
120
- setValue(fuzzyResults[0]);
121
- setFuzzyMode(false);
126
+ if (key.upArrow) {
127
+ setFuzzyIdx((i) => Math.max(0, i - 1));
128
+ return;
129
+ }
130
+ if (key.downArrow) {
131
+ setFuzzyIdx((i) => Math.min(Math.max(0, fuzzyResults.length - 1), i + 1));
132
+ return;
133
+ }
134
+ if (key.return) {
135
+ // I1 — Enter takes the HIGHLIGHTED result (was: always the first).
136
+ const pick = fuzzyResults[fuzzyIdx] ?? fuzzyResults[0];
137
+ if (pick) {
138
+ setValue(pick);
139
+ setFuzzyMode(false);
140
+ }
122
141
  return;
123
142
  }
124
143
  return;
125
144
  }
126
- // Task 7 — Esc discards the multiline draft. Runs AFTER the fuzzy
127
- // branch (fuzzy Esc closes the search) and only while this hook is
128
- // active at all (isActive: !disabled) — when a palette/modal overlay
129
- // is up the Footer is disabled, so overlay-Esc never races this.
145
+ // Task 7 — Esc discards the multiline draft.
130
146
  if (key.escape && bufferRef.current.length > 0) {
131
147
  updateBuffer([]);
148
+ // Chips that lived only in the discarded lines go with them; a chip
149
+ // still in the input keeps its text.
150
+ for (const chip of [...pastesRef.current.keys()]) {
151
+ if (!value.includes(chip))
152
+ pastesRef.current.delete(chip);
153
+ }
132
154
  onNotice?.('multiline draft discarded');
133
155
  return;
134
156
  }
135
- if (key.upArrow) {
136
- const next = Math.min(histIdx + 1, history.length - 1);
137
- setHistIdx(next);
138
- setValue(history[history.length - 1 - next] ?? '');
139
- return;
140
- }
141
- if (key.downArrow) {
142
- const next = Math.max(histIdx - 1, -1);
143
- setHistIdx(next);
144
- setValue(next === -1 ? '' : history[history.length - 1 - next] ?? '');
145
- return;
146
- }
147
- // 2026-06-16 — go dormant while a modal (ask_question / coaching picker)
148
- // owns input. Without this, arrow-key navigation INSIDE the modal also
149
- // hits this handler (Ink delivers every keystroke to every active
150
- // useInput), recalling a history line into the hidden Footer — so the
151
- // last command "comes back" in the input once the modal closes. Mirrors
152
- // why the TextInput is swapped for a placeholder when disabled.
157
+ // ↑/↓ belong to the TextInput now (visual rows first, then history).
153
158
  }, { isActive: !disabled });
154
159
  const handleSubmit = useCallback((line) => {
155
- // Task 7 — trailing `\` continues the message: strip the backslash,
156
- // hold the line (indentation preserved — this is for ABAP snippets),
157
- // clear the input, submit nothing. A bare `\` inserts a blank line.
160
+ // Task 7 — trailing `\` continues the message (chips stay folded until the final submit).
158
161
  if (line.endsWith('\\')) {
159
162
  updateBuffer([...bufferRef.current, line.slice(0, -1)]);
160
163
  setHistIdx(-1);
161
164
  setValue('');
162
165
  return;
163
166
  }
164
- // Task 7 — final submit with buffered lines: join into ONE message.
165
- // The onSubmit contract is unchanged (one string, may contain \n) —
166
- // App's beginSubmit / steering path takes it as a single message.
167
- // The bare-`/` and `/history` intercepts are intentionally skipped
168
- // here: mid-composition a `/` line is literal text (same pin as the
169
- // live-trigger suppression in App).
170
167
  if (bufferRef.current.length > 0) {
171
- const joined = [...bufferRef.current, line].join('\n').trim();
168
+ const joined = expandPasteChips([...bufferRef.current, line].join('\n'), pastesRef.current).trim();
172
169
  updateBuffer([]);
170
+ pastesRef.current.clear();
173
171
  setHistIdx(-1);
174
172
  setValue('');
175
173
  if (!joined)
176
174
  return;
177
- // The history file is one-entry-per-line; flatten so a multiline
178
- // message doesn't split into N bogus entries on the next load.
179
175
  appendHistory(joined.replace(/\n/g, ' '), history);
180
176
  onSubmit(joined);
181
177
  return;
182
178
  }
183
- if (!line.trim())
179
+ // I1 — chips expand on submit; history stores (and Ctrl+R matches) the expanded text.
180
+ const full = expandPasteChips(line, pastesRef.current);
181
+ if (!full.trim())
184
182
  return;
185
- appendHistory(line.trim(), history);
183
+ appendHistory(full.trim(), history);
184
+ pastesRef.current.clear();
186
185
  setHistIdx(-1);
187
186
  setValue('');
188
187
  if (line.trim() === '/') {
@@ -193,27 +192,29 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
193
192
  onHistoryTrigger();
194
193
  return;
195
194
  }
196
- onSubmit(line.trim());
195
+ onSubmit(full.trim());
197
196
  }, [onSubmit, onSlashTrigger, onHistoryTrigger, history, updateBuffer]);
198
- // Claude Code-style image paste (2026-09-23): Ctrl+V / Alt+V saves the
199
- // clipboard image and returns an [Image #N] chip; the turn loop swaps the
200
- // chip for the file path before the image attachment step.
201
197
  const handlePasteImage = useCallback(async () => {
202
198
  const chip = await pasteClipboardImage();
203
199
  if (!chip)
204
200
  onNotice?.('no image on the clipboard');
205
201
  return chip;
206
202
  }, [onNotice]);
203
+ const borderStyle = shouldUseUnicodeBoxChars() ? 'round' : 'classic';
204
+ const borderColor = roleProps('frame').color;
207
205
  if (fuzzyMode) {
208
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search...", maxDisplayWidth: Math.max(8, columns - 5) }), fuzzyResults.map((r, i) => (_jsx(Text, { dimColor: i > 0, wrap: "truncate-end", children: flattenHistoryEntry(r) }, i))), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
206
+ const g = getGlyphs();
207
+ const rowWidth = Math.max(4, boxWidth - BOX_CHROME - 2);
208
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: borderStyle, borderColor: borderColor, paddingX: 1, width: boxWidth, children: [_jsx(Text, { bold: true, children: "History search" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search...", maxDisplayWidth: Math.max(8, boxWidth - BOX_CHROME - 1) }), fuzzyResults.map((r, i) => (i === fuzzyIdx
209
+ ? _jsx(Text, { ...selectedProps(), wrap: "truncate-end", children: `${g.cursor} ${truncateToWidth(sanitizeControlChars(flattenHistoryEntry(r)), rowWidth)}` }, i)
210
+ : _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: ` ${truncateToWidth(sanitizeControlChars(flattenHistoryEntry(r)), rowWidth)}` }, i))), _jsx(Text, { ...roleProps('dim'), children: "\u2191\u2193 move \u00B7 Enter select \u00B7 Esc cancel" })] }));
209
211
  }
210
- // 2026-05-17 — when a modal owns input (coaching picker / ask_question),
211
- // render a dim placeholder INSTEAD of the live TextInput. Otherwise
212
- // keystrokes go to BOTH the modal AND this TextInput, and the queued
213
- // Footer submission fires after the modal resolves → spurious picker
214
- // for what the user thought was their modal answer.
212
+ // A card or palette owns input — show a static prompt instead of a live
213
+ // TextInput so keystrokes never double-fire (answer + stray submit). Only a
214
+ // waiting CARD says "(answer above to continue)" (spec §7); an open `/` or
215
+ // `@` palette or the timeline shows the prompt with nothing after it.
215
216
  if (disabled) {
216
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
217
+ return (_jsx(Box, { borderStyle: borderStyle, borderColor: borderColor, paddingX: 1, width: boxWidth, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: waitingForCard ? PROMPT : PROMPT_GLYPH }), waitingForCard ? _jsx(Text, { ...roleProps('dim'), children: "(answer above to continue)" }) : null] }) }));
217
218
  }
218
- return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [buffer.length > HELD_LINES_SHOWN && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length - HELD_LINES_SHOWN, " earlier line", buffer.length - HELD_LINES_SHOWN === 1 ? '' : 's'] })), buffer.slice(-HELD_LINES_SHOWN).map((line, i) => (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ['│ ', line] }, `held-${buffer.length}-${i}`))), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [buffer.length, " line", buffer.length === 1 ? '' : 's', " pending \u2014 Enter sends all, Esc discards, \\ continues"] })] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, onPasteImage: handlePasteImage, maxDisplayWidth: inputBudget })] })] }));
219
+ return (_jsx(Box, { flexDirection: "column", borderStyle: borderStyle, borderColor: borderColor, paddingX: 1, width: boxWidth, children: _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, onPasteImage: handlePasteImage, onLargePaste: handleLargePaste, wrapWidth: wrapWidth, maxLines: Math.max(1, Math.min(INPUT_MAX_LINES, maxLines ?? INPUT_MAX_LINES)), heldLines: buffer, prompt: PROMPT, onHistoryUp: historyUp, onHistoryDown: historyDown }) }));
219
220
  }
@@ -0,0 +1,122 @@
1
+ // src/ui/input-wrap.ts
2
+ //
3
+ // Piece 2 (spec §7) — pure layout for the input box. The Footer's input
4
+ // HARD-WRAPS itself (string-width cells, graphemes kept whole, tabs → 2
5
+ // spaces, other control chars → 1 space) so what we measure is what the
6
+ // terminal draws; it never relies on terminal soft-wrap, which broke Ink's
7
+ // erase accounting (the reason text-input.tsx used a one-row window).
8
+ import stringWidth from 'string-width';
9
+ /** The box grows to this many rows, then scrolls (spec §7). One definition, owned by F3's live-area budget. */
10
+ export { INPUT_MAX_LINES } from './live-area.js';
11
+ /** A paste of MORE than this many lines folds to `[Pasted N lines]` (spec §7). */
12
+ export const PASTE_FOLD_LINES = 6;
13
+ /** Tabs render as two spaces. */
14
+ export const INPUT_TAB = ' ';
15
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
16
+ function display(segment) {
17
+ if (segment === '\t')
18
+ return INPUT_TAB;
19
+ // C0, DEL and C1 (e.g. CSI \x9b) — same set as renderer/sanitize.ts — so
20
+ // pasted or recalled text can never drive the terminal.
21
+ // eslint-disable-next-line no-control-regex
22
+ return /^[\x00-\x1f\x7f-\x9f]+$/.test(segment) ? ' ' : segment;
23
+ }
24
+ function layout(value, width) {
25
+ const cells = [];
26
+ let line = 0;
27
+ let col = 0;
28
+ for (const { segment, index } of SEGMENTER.segment(value)) {
29
+ const disp = display(segment);
30
+ const w = stringWidth(disp);
31
+ if (col > 0 && col + w > width) {
32
+ line++;
33
+ col = 0;
34
+ }
35
+ cells.push({ index, end: index + segment.length, line, col, disp });
36
+ col += w;
37
+ }
38
+ return { cells, lastLine: line, lastCol: col };
39
+ }
40
+ /**
41
+ * Hard-wrap `value` into display lines of at most `width` cells. `cursor` is
42
+ * a UTF-16 offset into `value`; outside [0, value.length] means "no cursor"
43
+ * (cursorLine -1). A cursor at the very end needs one free cell, so an
44
+ * exactly-full last line puts it on a fresh empty line.
45
+ */
46
+ export function wrapInput(value, cursor, width) {
47
+ const w = Math.max(2, Math.floor(width));
48
+ const { cells, lastLine, lastCol } = layout(value, w);
49
+ const lines = Array.from({ length: lastLine + 1 }, () => '');
50
+ for (const c of cells)
51
+ lines[c.line] += c.disp;
52
+ if (cursor < 0 || cursor > value.length)
53
+ return { lines, cursorLine: -1, cursorCol: 0 };
54
+ const at = cells.find((c) => cursor >= c.index && cursor < c.end);
55
+ if (at)
56
+ return { lines, cursorLine: at.line, cursorCol: at.col };
57
+ if (lastCol + 1 > w) {
58
+ lines.push('');
59
+ return { lines, cursorLine: lastLine + 1, cursorCol: 0 };
60
+ }
61
+ return { lines, cursorLine: lastLine, cursorCol: lastCol };
62
+ }
63
+ /** Rows [start, end) to show: all when they fit, else maxLines rows with the cursor row inside. */
64
+ export function visibleRange(total, cursorRow, maxLines) {
65
+ const m = Math.max(1, Math.floor(maxLines));
66
+ if (total <= m)
67
+ return { start: 0, end: total };
68
+ const row = Math.min(Math.max(cursorRow, 0), total - 1);
69
+ const start = Math.min(Math.max(0, row - m + 1), total - m);
70
+ return { start, end: start + m };
71
+ }
72
+ /**
73
+ * The value offset for visual (line, col) — used by ↑/↓ inside the box.
74
+ * A col past the end of a wrapped (non-last) line lands on that line's last
75
+ * character, so the cursor stays on the row the user moved to.
76
+ */
77
+ export function indexAt(value, width, line, col) {
78
+ const { cells, lastLine } = layout(value, Math.max(2, Math.floor(width)));
79
+ if (line > lastLine)
80
+ return value.length;
81
+ const onLine = cells.filter((c) => c.line === line);
82
+ if (onLine.length === 0)
83
+ return value.length;
84
+ const hit = onLine.find((c) => c.col >= col);
85
+ if (hit)
86
+ return hit.index;
87
+ return line === lastLine ? value.length : onLine[onLine.length - 1].index;
88
+ }
89
+ /** Rows the box would need, uncapped — held lines (no cursor) + the value (cursor at end). */
90
+ export function countInputRows(held, value, width) {
91
+ const heldRows = held.reduce((n, l) => n + wrapInput(l, -1, width).lines.length, 0);
92
+ return heldRows + wrapInput(value, value.length, width).lines.length;
93
+ }
94
+ /** Lines in a pasted chunk (CR/CRLF normalised, trailing newlines ignored). */
95
+ export function pastedLineCount(text) {
96
+ return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n+$/, '').split('\n').length;
97
+ }
98
+ /** `[Pasted N lines]`, or `[Pasted N lines #k]` when that chip is already in use. */
99
+ export function nextPasteChip(text, existing) {
100
+ const n = pastedLineCount(text);
101
+ const base = `[Pasted ${n} lines]`;
102
+ if (!existing.has(base))
103
+ return base;
104
+ for (let k = 2;; k++) {
105
+ const chip = `[Pasted ${n} lines #${k}]`;
106
+ if (!existing.has(chip))
107
+ return chip;
108
+ }
109
+ }
110
+ /** Replace every chip with the text it stands for (done on submit). */
111
+ export function expandPasteChips(text, chips) {
112
+ if (chips.size === 0)
113
+ return text;
114
+ // ONE pass over the known chips (longest first, so `[Pasted 7 lines #2]` is
115
+ // never read as `[Pasted 7 lines]`): chip-looking text INSIDE a paste is
116
+ // never expanded a second time.
117
+ const alternation = [...chips.keys()]
118
+ .sort((a, b) => b.length - a.length)
119
+ .map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
120
+ .join('|');
121
+ return text.replace(new RegExp(alternation, 'g'), (chip) => chips.get(chip) ?? chip);
122
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Ctrl+C decision for the Ink REPL (fix round 3, item 4).
3
+ *
4
+ * - During a turn: the first press cancels the turn, a second press in the
5
+ * same turn exits hard (escape hatch if the abort never lands).
6
+ * - Idle: the first press only ARMS the exit — the App shows a dim
7
+ * "Press Ctrl+C again to exit" hint — and a second press inside
8
+ * IDLE_EXIT_WINDOW_MS exits. Same as Claude Code.
9
+ *
10
+ * Cards, sheets and palettes handle their own Ctrl+C (cancel that card
11
+ * only); the App never forwards a Ctrl+C here while one is open.
12
+ */
13
+ export const IDLE_EXIT_WINDOW_MS = 2000;
14
+ /**
15
+ * The ONE line a pending cancel prints (re-review 3): say only what will
16
+ * actually happen in that phase.
17
+ * - routing: the turn never starts (routing checks the cancel and returns);
18
+ * - prep: the turn is aborted the moment it creates its controller;
19
+ * - post: the turn is over; only an automatic next plan phase is stopped.
20
+ */
21
+ export function pendingCancelLine(phase, exiting, what = 'the next plan phase') {
22
+ if (phase === 'routing')
23
+ return exiting ? 'Turn cancelled · nothing was sent · exiting' : 'Turn cancelled · nothing was sent';
24
+ if (phase === 'post') {
25
+ return exiting
26
+ ? `Exiting when this turn has finished saving · ${what} will not start`
27
+ : `${what.charAt(0).toUpperCase()}${what.slice(1)} will not start automatically`;
28
+ }
29
+ return exiting ? 'Stopping the turn · exiting when it has saved' : 'Stopping the turn · it stops as it starts';
30
+ }
31
+ /**
32
+ * Round 4 item 9a — what a post-turn cancel stops, by what is queued: a plan
33
+ * resume (or a plan chain that has not queued yet) is "the next plan phase";
34
+ * any other queued command (a dispatch_skill switch) is named as itself.
35
+ */
36
+ export function queuedWorkLabel(queued) {
37
+ const cmd = (queued ?? '').trim();
38
+ if (!cmd || /^\/abap-plan\s+--resume\b/.test(cmd))
39
+ return 'the next plan phase';
40
+ return `the switch to ${cmd.split(/\s+/)[0]}`;
41
+ }
42
+ /** Round 4 item 9b — how long an exit waits for post-turn work (saving, audit) to finish. */
43
+ export const EXIT_TAIL_CAP_MS = 5000;
44
+ /**
45
+ * Round 4 item 9b — wait for `tail` (the in-flight dispatch) to settle, but
46
+ * never longer than `capMs`. A rejected tail counts as settled; never throws.
47
+ */
48
+ export async function settleWithin(tail, capMs) {
49
+ if (!tail)
50
+ return 'settled';
51
+ let timer;
52
+ const cap = new Promise((r) => { timer = setTimeout(() => r('timeout'), capMs); });
53
+ try {
54
+ return await Promise.race([tail.then(() => 'settled', () => 'settled'), cap]);
55
+ }
56
+ finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+ export function decideInterrupt(s) {
61
+ if (s.turnActive)
62
+ return s.turnPresses === 0 ? 'abort-turn' : 'force-exit';
63
+ // Post-turn work that cannot start anything more: nothing to cancel, so
64
+ // Ctrl+C behaves as at idle (arm, then exit).
65
+ const idleLike = s.phase === 'post' && !s.canChain;
66
+ if (s.busy && !idleLike)
67
+ return s.turnPresses === 0 ? 'cancel-pending' : 'force-exit';
68
+ if (s.lastIdleAt > 0 && s.now - s.lastIdleAt <= IDLE_EXIT_WINDOW_MS)
69
+ return 'exit';
70
+ return 'arm';
71
+ }
72
+ export function createPendingTurnCancel() {
73
+ let pending = false;
74
+ return {
75
+ request() {
76
+ const first = !pending;
77
+ pending = true;
78
+ return first;
79
+ },
80
+ get pending() {
81
+ return pending;
82
+ },
83
+ applyTo(controller) {
84
+ if (!pending)
85
+ return false;
86
+ pending = false;
87
+ controller.abort();
88
+ return true;
89
+ },
90
+ clear() {
91
+ pending = false;
92
+ },
93
+ };
94
+ }
95
+ export function autoDispatchDecision(s, cmd) {
96
+ if (s.done)
97
+ return { kind: 'skip' };
98
+ if (s.exitRequested || s.cancelPending) {
99
+ const what = queuedWorkLabel(cmd).replace(/^the /, '');
100
+ return { kind: 'not-started', line: `${what.charAt(0).toUpperCase()}${what.slice(1)} not started · run it when ready: ${cmd}` };
101
+ }
102
+ return { kind: 'go' };
103
+ }