@cspeach/cli 1.1.18 → 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 (224) 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 +58 -21
  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/standards/standards-init.js +1 -1
  132. package/dist/test-helpers/answer-any.js +46 -0
  133. package/dist/tools/approval.js +27 -11
  134. package/dist/tools/ask-question.js +47 -5
  135. package/dist/tools/filesystem/file-write.js +19 -2
  136. package/dist/tools/fiori/fe-extend.js +16 -1
  137. package/dist/tools/fiori/fe-scaffold.js +105 -3
  138. package/dist/tools/fiori/html-escapes.js +28 -0
  139. package/dist/tools/fiori/metadata/audit.js +605 -0
  140. package/dist/tools/fiori/metadata/references.js +104 -0
  141. package/dist/tools/fiori/metadata/types.js +8 -0
  142. package/dist/tools/fiori/preview/app-guard.js +215 -0
  143. package/dist/tools/fiori/preview/env.js +193 -0
  144. package/dist/tools/fiori/preview/readiness.js +127 -0
  145. package/dist/tools/fiori/preview/registry.js +412 -0
  146. package/dist/tools/fiori/preview/start.js +469 -0
  147. package/dist/tools/fiori/samples/loader.js +20 -5
  148. package/dist/tools/fiori/scaffold.js +18 -6
  149. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  150. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  151. package/dist/tools/fiori/smoke/assertions.js +161 -24
  152. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  153. package/dist/tools/fiori/smoke/batch.js +273 -0
  154. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  155. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  156. package/dist/tools/fiori/smoke/driver.js +171 -4
  157. package/dist/tools/fiori/smoke/evidence.js +35 -0
  158. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  159. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  160. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  161. package/dist/tools/fiori/tools.js +651 -7
  162. package/dist/tools/fiori/ui5-version.js +16 -0
  163. package/dist/tools/index.js +8 -0
  164. package/dist/tools/local-build.js +6 -0
  165. package/dist/tools/sap-read.js +16 -1
  166. package/dist/tools/sap-write.js +122 -10
  167. package/dist/tools/shell/shell_exec.js +30 -12
  168. package/dist/tools/syntax-state.js +67 -0
  169. package/dist/tools/todo.js +23 -18
  170. package/dist/tools/transport-fit.js +462 -0
  171. package/dist/tools/transport-resolution.js +3 -1
  172. package/dist/tools/transport.js +33 -7
  173. package/dist/tools/verify.js +184 -8
  174. package/dist/ui/app.js +284 -146
  175. package/dist/ui/approval-modal.js +41 -15
  176. package/dist/ui/ask-answer-text.js +17 -0
  177. package/dist/ui/body.js +25 -6
  178. package/dist/ui/card-slot.js +98 -0
  179. package/dist/ui/checklist.js +9 -0
  180. package/dist/ui/coaching-picker-classic.js +8 -5
  181. package/dist/ui/command-palette.js +6 -4
  182. package/dist/ui/confirm-request.js +18 -0
  183. package/dist/ui/context-grid.js +2 -1
  184. package/dist/ui/file-palette.js +6 -4
  185. package/dist/ui/files-card-emitter.js +19 -0
  186. package/dist/ui/footer-line.js +16 -0
  187. package/dist/ui/footer.js +105 -104
  188. package/dist/ui/input-wrap.js +122 -0
  189. package/dist/ui/interrupt.js +103 -0
  190. package/dist/ui/key-burst.js +117 -0
  191. package/dist/ui/line-resolution.js +4 -3
  192. package/dist/ui/live-area.js +71 -0
  193. package/dist/ui/login-banner.js +54 -43
  194. package/dist/ui/rewind-panel.js +76 -24
  195. package/dist/ui/role-props.js +47 -0
  196. package/dist/ui/sap-state-store.js +1 -0
  197. package/dist/ui/session-timeline.js +49 -3
  198. package/dist/ui/skill-picker.js +5 -47
  199. package/dist/ui/text-input.js +128 -24
  200. package/dist/ui/todo-emitter.js +19 -0
  201. package/dist/ui/turn-status-emitter.js +28 -2
  202. package/dist/ui/turn-status.js +16 -19
  203. package/dist/ui/use-card-request.js +44 -0
  204. package/dist/ui/widgets/ask-card.js +521 -0
  205. package/dist/ui/widgets/bar-chart.js +11 -5
  206. package/dist/ui/widgets/coaching-picker.js +50 -55
  207. package/dist/ui/widgets/confirm-card.js +193 -0
  208. package/dist/ui/widgets/dep-graph.js +3 -2
  209. package/dist/ui/widgets/diff-viewer.js +6 -5
  210. package/dist/ui/widgets/files-card.js +97 -0
  211. package/dist/ui/widgets/question-card.js +2 -1
  212. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  213. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  214. package/dist/ui/widgets/stack-frames.js +2 -1
  215. package/dist/upgrade-check.js +39 -8
  216. package/package.json +11 -2
  217. package/test-harness/fe-draft/app.ts +188 -0
  218. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  219. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  220. package/test-harness/fe-draft/routes.ts +224 -0
  221. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  222. package/test-harness/fe-draft/server.ts +225 -0
  223. package/test-harness/fe-draft/variants.ts +196 -0
  224. package/vitest.smoke-e2e.config.ts +20 -0
@@ -1,45 +1,51 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
+ import stringWidth from 'string-width';
4
5
  import { PICK_CANCELLED } from '../skill-picker.js';
5
- const PEACH = '#FFCBA4';
6
+ import { getGlyphs } from '../../renderer/glyphs.js';
7
+ import { roleProps, selectedProps } from '../role-props.js';
8
+ import { truncateToWidth, uiWidth } from '../../renderer/ui-width.js';
9
+ import { sanitizeControlChars } from '../../renderer/sanitize.js';
10
+ export const COACHING_HINT = '↑↓ move · 1-9/Enter picks · Enter with nothing selected cancels · Esc cancels';
6
11
  /**
7
- * Ink-mode coaching picker — compact single-line-per-option layout.
12
+ * Sanitize, then cut to `cells` terminal cells (same rule as the ask card):
13
+ * the prompt is user text and must not drive the terminal; rows are pre-cut
14
+ * with string-width and Ink's truncate-end is only the safety net.
15
+ */
16
+ function fit(text, cells) {
17
+ return truncateToWidth(sanitizeControlChars(text), Math.max(0, cells));
18
+ }
19
+ /**
20
+ * Ink-mode skill picker on the AskCard look (piece 2 / Q4, spec §3).
21
+ *
22
+ * Pick a skill for "What is the best part…?"
8
23
  *
9
- * Phase D3+ (2026-05-16): the prior layout used a bordered Box per
10
- * option with 3 text rows each (skill+confidence, Does:, Pick if:).
11
- * For 5 options that's ~25 lines — exceeded available vertical space
12
- * once mounted inline between Body and Footer, and Ink overflow-
13
- * clipped only the BOTTOM line of each option leaving a garbled
14
- * picker (screenshot 71). This rewrite uses one line per option +
15
- * a "details" line only for the highlighted entry, total height
16
- * bounded to ~8 lines regardless of option count.
24
+ * ❯ 1. /abap-explain Plain-language code explainer
25
+ * Pick if: pick this to teach code to a human
26
+ * 2. /abap-review Code review vs quality criteria
17
27
  *
18
- * Layout (example, 3 options):
19
- * ╭ Pick a skill for: "What is the best part…?" ───────────╮
20
- * │ ▸ /abap-explain 72% Plain-language code explainer │
21
- * │ /abap-review 0% Code review vs quality criteria│
22
- * │ /abap-document 0% Generate ABAP documentation │
23
- * │ │
24
- * │ ▸ /abap-explain — Pick if: pick this to teach code to a human │
25
- * │ ↑/↓ navigate · Enter select · Esc cancel │
26
- * ╰──────────────────────────────────────────────────────────╯
28
+ * ↑↓ move · 1-9/Enter picks · Enter with nothing selected cancels · Esc cancels
29
+ *
30
+ * INVARIANT (spec, pinned): with nothing highlighted — preSelect false, or
31
+ * ↑ past the first row — Enter resolves PICK_CANCELLED. Starting cursor:
32
+ * preferSkill (if listed) → first row when preSelect → nothing. The
33
+ * confidence percentage is gone; results are unchanged (skill | PICK_CANCELLED).
27
34
  */
28
- export function CoachingPicker({ params, onResolve }) {
29
- // 2026-05-17 — initial cursor position priority:
30
- // 1. preferSkill (the just-used skill, if present in options) — lets
31
- // the user confirm "same skill as last turn" with one Enter press
32
- // 2. preSelect=true → top-1 (classifier's best guess)
33
- // 3. -1 → no selection, user must arrow-key to a choice
35
+ export function CoachingPicker({ params, onResolve, width }) {
36
+ const g = getGlyphs();
37
+ const dim = roleProps('dim');
38
+ const w = width ?? uiWidth();
39
+ // Content width: card width minus paddingX={1} on both sides.
40
+ const inner = Math.max(1, w - 2);
34
41
  const preferIdx = params.preferSkill
35
42
  ? params.options.findIndex((o) => o.skill === params.preferSkill)
36
43
  : -1;
37
- const initialCursor = preferIdx >= 0
38
- ? preferIdx
39
- : params.preSelect ? 0 : -1;
44
+ const initialCursor = preferIdx >= 0 ? preferIdx : params.preSelect ? 0 : -1;
40
45
  const [cursor, setCursor] = useState(initialCursor);
41
- useInput((_input, key) => {
42
- if (key.escape) {
46
+ useInput((input, key) => {
47
+ // Ctrl+C cancels like Esc (same as ConfirmCard / FilesCard).
48
+ if (key.escape || (key.ctrl && input === 'c')) {
43
49
  onResolve(PICK_CANCELLED);
44
50
  return;
45
51
  }
@@ -51,6 +57,13 @@ export function CoachingPicker({ params, onResolve }) {
51
57
  setCursor((c) => Math.min(params.options.length - 1, c + 1));
52
58
  return;
53
59
  }
60
+ const digit = /^[1-9]$/.test(input) ? Number(input) - 1 : -1;
61
+ if (digit >= 0) {
62
+ const picked = params.options[digit];
63
+ if (picked)
64
+ onResolve(picked.skill);
65
+ return;
66
+ }
54
67
  if (key.return) {
55
68
  if (cursor < 0) {
56
69
  onResolve(PICK_CANCELLED);
@@ -61,28 +74,10 @@ export function CoachingPicker({ params, onResolve }) {
61
74
  onResolve(chosen.skill);
62
75
  }
63
76
  }, { isActive: true });
64
- const promptPreview = params.prompt.length > 60
65
- ? params.prompt.slice(0, 57) + '…'
66
- : params.prompt;
67
- const highlighted = cursor >= 0 ? params.options[cursor] : undefined;
68
- return (
69
- // flexShrink={0} (2026-05-17) — without this, Ink's default
70
- // flexShrink: 1 lets the picker get squashed when vertical space is
71
- // tight, and the TOP options clip off (only the bottom one remained
72
- // visible — see screenshot 74). flexShrink={0} guarantees the picker
73
- // claims its natural height; Body shrinks to make room (and Body
74
- // content stays recoverable via PgUp/Ctrl+B scroll).
75
- _jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsxs(Text, { bold: true, children: ["Pick a skill for: ", _jsxs(Text, { color: "cyan", children: ["\"", promptPreview, "\""] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.options.map((opt, i) => {
76
- const isCursor = i === cursor;
77
- const arrow = isCursor ? '▸ ' : ' ';
78
- // 2026-05-17 — hide the "0%" cruft. Confidence is 0 for any
79
- // alternate / fallback option (we never had a real score for
80
- // them), AND for top-1 picks where the classifier itself
81
- // returned 0. In both cases displaying "0%" looks broken.
82
- // Show "%" only when there's a meaningful number.
83
- const pctNum = Math.round(opt.confidence * 100);
84
- const pct = pctNum > 0 ? `${pctNum.toString().padStart(3, ' ')}%` : ' ';
85
- const name = `/${opt.skill}`.padEnd(20, ' ');
86
- return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [arrow, name, " ", pct, " ", opt.whatItDoes.slice(0, 60)] }, opt.skill));
87
- }) }), highlighted && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Pick if: ", highlighted.whenToPick] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" }) })] }));
77
+ const nameWidth = Math.max(...params.options.map((o) => o.skill.length + 1)) + 2;
78
+ return (_jsxs(Box, { flexDirection: "column", width: w, paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: fit(`Pick a skill for "${params.prompt}"`, inner) }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.options.map((opt, i) => {
79
+ const cur = i === cursor;
80
+ const head = fit(`${cur ? `${g.cursor} ` : ' '}${i + 1}. ${`/${opt.skill}`.padEnd(nameWidth, ' ')}`, inner);
81
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", ...(cur ? selectedProps() : {}), children: [head, _jsx(Text, { ...(cur ? {} : dim), children: fit(opt.whatItDoes, inner - stringWidth(head)) })] }), cur && opt.whenToPick ? (_jsx(Text, { ...dim, wrap: "truncate-end", children: fit(` Pick if: ${opt.whenToPick}`, inner) })) : null] }, opt.skill));
82
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { ...dim, wrap: "truncate-end", children: fit(COACHING_HINT, inner) }) })] }));
88
83
  }
@@ -0,0 +1,193 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // src/ui/widgets/confirm-card.tsx
3
+ //
4
+ // Piece 2 §5 — the one confirm card (APPROVAL / SAFETY CHECK / CANNOT BE
5
+ // UNDONE, plus the ADVISORY and QUESTION confirms). Tag chip on the top edge,
6
+ // bold title, dim facts, numbered choices, dim footnote on the bottom edge.
7
+ //
8
+ // Keys (deliberately NOT the question-card keys):
9
+ // ↑/↓ move the cursor. Digits 1–9 pick on APPROVAL / QUESTION / ADVISORY
10
+ // cards; on SAFETY CHECK and CANNOT BE UNDONE cards they only move it.
11
+ // Enter confirms the highlighted choice.
12
+ // Esc / Ctrl+C → { value: null, cancelled: true }.
13
+ // request.aliases (e.g. y/n/d, or typed words) resolve immediately.
14
+ // Anything received in the first CONFIRM_INPUT_GUARD_MS after mount is
15
+ // dropped: a `1` or Enter typed before the card opened can never apply.
16
+ import { useRef, useState } from 'react';
17
+ import { Box, Text, useInput } from 'ink';
18
+ import stringWidth from 'string-width';
19
+ import { roleProps, selectedProps } from '../role-props.js';
20
+ import { getBoxChars } from '../ascii-chars.js';
21
+ import { getGlyphs } from '../../renderer/glyphs.js';
22
+ import { truncateToWidth } from '../../renderer/ui-width.js';
23
+ import { sanitizeControlChars } from '../../renderer/sanitize.js';
24
+ // Q1b's shared hook — the one raw-stdin reclaim for every card (no second copy).
25
+ import { useReclaimRawStdin } from './reclaim-raw-stdin.js';
26
+ export const CONFIRM_INPUT_GUARD_MS = 150;
27
+ /**
28
+ * The card's full rendered height for the live-area budget (F3 liveBudget
29
+ * `cardRows`): top edge + title + one row per fact + the gap above the
30
+ * choices + one row per choice + the hint + bottom edge. Every row is
31
+ * truncate-end, so nothing wraps into extra rows.
32
+ */
33
+ export function estimateConfirmCardRows(req) {
34
+ return 5 + (req.facts?.length ?? 0) + req.choices.length;
35
+ }
36
+ /**
37
+ * Owner decision (fix-round-3 deferred M1): a digit PICKS only on ordinary
38
+ * cards (APPROVAL, QUESTION, ADVISORY). A SAFETY CHECK or CANNOT BE UNDONE
39
+ * card needs move-then-Enter — a digit only moves the cursor there — so one
40
+ * stray key can never pass a safety gate or release a transport. An
41
+ * allow-list, so any tag added later fails safe (move + Enter) until it is
42
+ * deliberately listed here.
43
+ */
44
+ const DIGIT_PICK_TAGS = new Set(['APPROVAL', 'QUESTION', 'ADVISORY']);
45
+ export function digitsPickOn(tag) {
46
+ return DIGIT_PICK_TAGS.has(tag);
47
+ }
48
+ export const TAG_ROLE = {
49
+ APPROVAL: 'sap',
50
+ 'SAFETY CHECK': 'warn',
51
+ 'CANNOT BE UNDONE': 'bad',
52
+ ADVISORY: 'warn',
53
+ QUESTION: 'sap',
54
+ };
55
+ /**
56
+ * One edge line in three segments so the label can take its own colour.
57
+ * lead + label + tail is exactly `width` cells whenever width ≥ 7 (the
58
+ * label is cut to width − 6 cells with …; wide characters count two).
59
+ */
60
+ export function edgeSegments(corners, h, label, width) {
61
+ const [left, right] = corners;
62
+ if (!label)
63
+ return { lead: left + h.repeat(Math.max(0, width - 2)), label: '', tail: right };
64
+ const shown = truncateToWidth(label, Math.max(1, width - 6));
65
+ // Cells, not code points: a wide-character label must not widen the edge.
66
+ const fill = Math.max(1, width - 5 - stringWidth(shown));
67
+ return { lead: `${left}${h} `, label: shown, tail: ` ${h.repeat(fill)}${right}` };
68
+ }
69
+ /**
70
+ * Alias matching: a single key resolves at once; a word (e.g. 'abort') is
71
+ * collected letter by letter and resolves on its last letter; a pasted word
72
+ * resolves whole. Digits never reach here (they move the cursor).
73
+ */
74
+ export function matchAlias(aliases, input, word) {
75
+ if (!aliases || input.length === 0)
76
+ return undefined;
77
+ const k = input.toLowerCase();
78
+ if (aliases[k] !== undefined) {
79
+ word.current = '';
80
+ return aliases[k];
81
+ }
82
+ const next = word.current + k;
83
+ if (aliases[next] !== undefined) {
84
+ word.current = '';
85
+ return aliases[next];
86
+ }
87
+ word.current = Object.keys(aliases).some((a) => a.startsWith(next)) ? next : '';
88
+ return undefined;
89
+ }
90
+ /**
91
+ * Card text can carry model-supplied words (the plan summary). Neutralise
92
+ * control characters, then pre-cut to `cells` with string-width: Ink's own
93
+ * truncate-end miscounts some emoji, so it is only the safety net here.
94
+ */
95
+ function fit(text, cells) {
96
+ return truncateToWidth(sanitizeControlChars(text), cells);
97
+ }
98
+ const inkBorder = (b) => ({
99
+ topLeft: b.tl, top: b.h, topRight: b.tr, right: b.v,
100
+ bottomRight: b.br, bottom: b.h, bottomLeft: b.bl, left: b.v,
101
+ });
102
+ /** The frame every card in the slot shares: tag chip top edge, footnote bottom edge. */
103
+ export function CardFrame({ tag, footnote, width, children }) {
104
+ const box = getBoxChars();
105
+ const frame = roleProps('frame');
106
+ const top = edgeSegments([box.tl, box.tr], box.h, tag, width);
107
+ const bottom = edgeSegments([box.bl, box.br], box.h, footnote, width);
108
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: top.lead }), _jsx(Text, { ...roleProps(TAG_ROLE[tag], { bold: true }), children: top.label }), _jsx(Text, { ...frame, children: top.tail })] }), _jsx(Box, { flexDirection: "column", width: width, paddingX: 1, borderStyle: inkBorder(box), borderTop: false, borderBottom: false, borderColor: frame.color, children: children }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...frame, children: bottom.lead }), _jsx(Text, { ...roleProps('dim'), children: bottom.label }), _jsx(Text, { ...frame, children: bottom.tail })] })] }));
109
+ }
110
+ export function ConfirmCard({ request, width, onResolve, onInterruptTurn }) {
111
+ // Re-assert raw stdin on mount: an earlier inquirer prompt can leave Ink's
112
+ // raw-mode bookkeeping stale, and the card would then get no keys.
113
+ useReclaimRawStdin();
114
+ const g = getGlyphs();
115
+ const last = Math.max(0, request.choices.length - 1);
116
+ const [cursor, setCursor] = useState(() => Math.min(Math.max(request.initial ?? 0, 0), last));
117
+ const mountedAt = useRef(Date.now());
118
+ const done = useRef(false);
119
+ const word = useRef('');
120
+ const digitsPick = digitsPickOn(request.tag);
121
+ const finish = (r) => {
122
+ if (done.current)
123
+ return;
124
+ done.current = true;
125
+ onResolve(r);
126
+ };
127
+ useInput((input, key) => {
128
+ if (done.current)
129
+ return;
130
+ // Round 8 item 1 — Ctrl+C is never an answer, so the mount guard does not
131
+ // swallow it: it cancels the card and stops the turn.
132
+ if (key.ctrl && input === 'c') {
133
+ finish({ value: null, cancelled: true });
134
+ onInterruptTurn?.();
135
+ return;
136
+ }
137
+ if (Date.now() - mountedAt.current < CONFIRM_INPUT_GUARD_MS)
138
+ return;
139
+ if (key.escape) {
140
+ finish({ value: null, cancelled: true });
141
+ return;
142
+ }
143
+ if (key.upArrow) {
144
+ setCursor((c) => Math.max(0, c - 1));
145
+ return;
146
+ }
147
+ if (key.downArrow) {
148
+ setCursor((c) => Math.min(last, c + 1));
149
+ return;
150
+ }
151
+ if (key.return) {
152
+ const chosen = request.choices[cursor];
153
+ if (chosen)
154
+ finish({ value: chosen.value });
155
+ return;
156
+ }
157
+ if (/^[1-9]$/.test(input)) {
158
+ const i = Number(input) - 1;
159
+ if (i > last)
160
+ return;
161
+ // Fix round 3 (item 9b): a digit PICKS, the same as on question cards
162
+ // ("1" sat unanswered for 7 minutes live). SAFETY CHECK and CANNOT BE
163
+ // UNDONE cards keep move-then-Enter (see digitsPickOn).
164
+ if (digitsPick)
165
+ finish({ value: request.choices[i].value });
166
+ else
167
+ setCursor(i);
168
+ return;
169
+ }
170
+ if (key.ctrl || key.meta)
171
+ return;
172
+ const hit = matchAlias(request.aliases, input, word);
173
+ // No new values: an alias only answers with a choice the card offers.
174
+ if (hit !== undefined && request.choices.some((c) => c.value === hit))
175
+ finish({ value: hit });
176
+ });
177
+ const inner = Math.max(1, width - 4);
178
+ const dim = roleProps('dim');
179
+ const titleProps = request.tag === 'CANNOT BE UNDONE' ? roleProps('bad', { bold: true }) : { bold: true };
180
+ const letters = Object.keys(request.aliases ?? {}).filter((k) => k.length === 1);
181
+ const digits = Math.min(9, request.choices.length);
182
+ const hint = (digitsPick
183
+ ? `1-${digits} picks · ↑↓ move · Enter chooses · Esc cancels`
184
+ : `Enter chooses · Esc cancels · ↑↓ or 1-${digits} move`)
185
+ + (letters.length > 0 ? ` · keys ${letters.join('/')}` : '');
186
+ return (_jsxs(CardFrame, { tag: request.tag, footnote: request.footnote, width: width, children: [_jsx(Text, { wrap: "truncate-end", ...titleProps, children: fit(request.title, inner) }), (request.facts ?? []).map((f, i) => (_jsx(Text, { wrap: "truncate-end", ...dim, children: fit(f, inner) }, `fact-${i}`))), _jsx(Box, { flexDirection: "column", marginTop: 1, children: request.choices.map((c, i) => {
187
+ const on = i === cursor;
188
+ const main = fit(`${on ? g.cursor : ' '} ${i + 1}. ${c.label}`, inner);
189
+ const room = inner - stringWidth(main);
190
+ const hintText = c.hint && room > 2 ? fit(` ${c.hint}`, room) : '';
191
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...(on ? selectedProps() : {}), children: main }), hintText ? _jsx(Text, { ...dim, children: hintText }) : null] }, c.value));
192
+ }) }), _jsx(Text, { wrap: "truncate-end", ...dim, children: fit(hint, inner) })] }));
193
+ }
@@ -1,9 +1,10 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { roleProps } from '../role-props.js';
3
4
  export function DepGraph({ data }) {
4
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Dependencies \u2014 ", data.root] }), data.children.map((c, i) => {
5
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 1, children: [_jsxs(Text, { ...roleProps('sap', { bold: true }), children: ["Dependencies \u2014 ", data.root] }), data.children.map((c, i) => {
5
6
  const isLast = i === data.children.length - 1;
6
7
  const prefix = isLast ? '└─' : '├─';
7
- return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [prefix, " "] }), _jsx(Text, { color: "yellow", children: c.name }), _jsxs(Text, { color: "gray", children: [" (", c.type, ") "] }), _jsx(Text, { dimColor: true, children: c.relation })] }, c.name));
8
+ return (_jsxs(Box, { children: [_jsxs(Text, { ...roleProps('dim'), children: [prefix, " "] }), _jsx(Text, { ...roleProps('warn'), children: c.name }), _jsxs(Text, { ...roleProps('dim'), children: [" (", c.type, ") "] }), _jsx(Text, { dimColor: true, children: c.relation })] }, c.name));
8
9
  })] }));
9
10
  }
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { roleProps } from '../role-props.js';
3
4
  export function DiffViewer({ data }) {
4
5
  const lines = data.unified.split('\n');
5
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: "gray", children: "Diff" }), lines.map((line, idx) => {
6
- const color = line.startsWith('+') ? 'green' :
7
- line.startsWith('-') ? 'red' :
8
- line.startsWith('@@') ? 'cyan' : 'gray';
9
- return (_jsx(Text, { color: color, bold: line.startsWith('@@'), children: line }, idx));
6
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('frame').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('dim', { bold: true }), children: "Diff" }), lines.map((line, idx) => {
7
+ const role = line.startsWith('+') ? 'ok' :
8
+ line.startsWith('-') ? 'bad' :
9
+ line.startsWith('@@') ? 'sap' : 'dim';
10
+ return (_jsx(Text, { ...roleProps(role, { bold: line.startsWith('@@') }), children: line }, idx));
10
11
  })] }));
11
12
  }
@@ -0,0 +1,97 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // src/ui/widgets/files-card.tsx
3
+ //
4
+ // Piece 2 §6.4 — the `/files` picker as a card-slot client under Ink (was an
5
+ // inquirer `search` drawn over the Ink frame). Type to filter by file name
6
+ // (same match as today), ↑/↓ move, Enter picks, Esc/Ctrl+C cancel. Typed-ahead
7
+ // input in the first CONFIRM_INPUT_GUARD_MS is dropped, like every card.
8
+ // The list is bounded to FILES_CARD_ROWS rows (spec §2 height cap).
9
+ import { useRef, useState } from 'react';
10
+ import { Text, useInput } from 'ink';
11
+ import stringWidth from 'string-width';
12
+ import { CardFrame, CONFIRM_INPUT_GUARD_MS } from './confirm-card.js';
13
+ import { roleProps, selectedProps } from '../role-props.js';
14
+ import { getGlyphs } from '../../renderer/glyphs.js';
15
+ import { truncateToWidth } from '../../renderer/ui-width.js';
16
+ import { sanitizeControlChars } from '../../renderer/sanitize.js';
17
+ import { useReclaimRawStdin } from './reclaim-raw-stdin.js';
18
+ export const FILES_CARD_ROWS = 8;
19
+ export function filterFileItems(items, query) {
20
+ const q = query.toLowerCase();
21
+ return q.length === 0 ? items : items.filter((i) => i.value.toLowerCase().includes(q));
22
+ }
23
+ /** File names come from disk: neutralise control characters, pre-cut by cells. */
24
+ function fit(text, cells) {
25
+ return truncateToWidth(sanitizeControlChars(text), Math.max(1, cells));
26
+ }
27
+ export function FilesCard({ request, width, onResolve }) {
28
+ // Same raw-stdin reclaim as every card (an earlier inquirer prompt can leave
29
+ // Ink's raw-mode bookkeeping stale).
30
+ useReclaimRawStdin();
31
+ const g = getGlyphs();
32
+ const [query, setQuery] = useState('');
33
+ const [cursor, setCursor] = useState(0);
34
+ const mountedAt = useRef(Date.now());
35
+ const done = useRef(false);
36
+ const shown = filterFileItems(request.items, query);
37
+ const finish = (r) => {
38
+ if (done.current)
39
+ return;
40
+ done.current = true;
41
+ onResolve(r);
42
+ };
43
+ useInput((input, key) => {
44
+ if (done.current)
45
+ return;
46
+ if (Date.now() - mountedAt.current < CONFIRM_INPUT_GUARD_MS)
47
+ return;
48
+ if (key.escape || (key.ctrl && input === 'c')) {
49
+ finish({ value: null, cancelled: true });
50
+ return;
51
+ }
52
+ if (key.upArrow) {
53
+ setCursor((c) => Math.max(0, c - 1));
54
+ return;
55
+ }
56
+ if (key.downArrow) {
57
+ setCursor((c) => Math.min(Math.max(0, shown.length - 1), c + 1));
58
+ return;
59
+ }
60
+ if (key.return) {
61
+ const it = shown[cursor];
62
+ if (it)
63
+ finish({ value: it.value });
64
+ return;
65
+ }
66
+ if (key.backspace || key.delete) {
67
+ setQuery((q) => q.slice(0, -1));
68
+ setCursor(0);
69
+ return;
70
+ }
71
+ if (key.ctrl || key.meta || key.tab)
72
+ return;
73
+ // eslint-disable-next-line no-control-regex
74
+ const printable = input.replace(/[\u0000-\u001f\u007f]/g, '');
75
+ if (printable.length > 0) {
76
+ setQuery((q) => q + printable);
77
+ setCursor(0);
78
+ }
79
+ });
80
+ const start = cursor < FILES_CARD_ROWS ? 0 : cursor - FILES_CARD_ROWS + 1;
81
+ const visible = shown.slice(start, start + FILES_CARD_ROWS);
82
+ const hidden = shown.length - start - visible.length;
83
+ const inner = Math.max(1, width - 4);
84
+ const dim = roleProps('dim');
85
+ const searchLabel = 'Search ';
86
+ // A long query is cut by cells (with …) so the row never wraps.
87
+ const queryShown = fit(query, inner - stringWidth(searchLabel) - 1);
88
+ return (_jsxs(CardFrame, { tag: "QUESTION", footnote: "/files", width: width, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: fit(request.title, inner) }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...dim, children: searchLabel }), _jsx(Text, { children: queryShown }), _jsx(Text, { ...roleProps('sap'), children: "_" })] }), visible.length === 0
89
+ ? _jsx(Text, { ...dim, children: "No file matches" })
90
+ : visible.map((it, i) => {
91
+ const on = start + i === cursor;
92
+ const main = fit(`${on ? g.cursor : ' '} ${it.label}`, inner);
93
+ const room = inner - stringWidth(main);
94
+ const detail = it.detail && room > 2 ? fit(` ${g.info} ${it.detail}`, room) : '';
95
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...(on ? selectedProps() : {}), children: main }), detail ? _jsx(Text, { ...dim, children: detail }) : null] }, it.value));
96
+ }), hidden > 0 ? _jsx(Text, { ...dim, children: `${g.more} ${hidden} more` }) : null, _jsx(Text, { wrap: "truncate-end", ...dim, children: fit('Type to search · ↑↓ move · Enter picks · Esc cancels', inner) })] }));
97
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { roleProps } from '../role-props.js';
3
4
  /**
4
5
  * Ink-mode rendering of cspeach-widget:question. Uses a double-border box
5
6
  * so it's visually distinct from the single-border tool-widget. No
@@ -7,5 +8,5 @@ import { Box, Text } from 'ink';
7
8
  * and types their answer as the next REPL prompt.
8
9
  */
9
10
  export function QuestionCard({ data }) {
10
- return (_jsxs(Box, { borderStyle: "double", borderColor: "cyan", flexDirection: "column", padding: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Question: ", data.id] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: data.question }) }), data.context ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Context: ", data.context] }) })) : null, (data.kind === 'choice' || data.kind === 'multi') && data.choices ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: data.kind === 'choice' ? 'Pick one:' : 'Pick any (comma-separate):' }), data.choices.map((c, i) => (_jsxs(Text, { children: ["[", i + 1, "] ", c.label] }, c.value)))] })) : null] }));
11
+ return (_jsxs(Box, { borderStyle: "double", borderColor: roleProps('sap').color, flexDirection: "column", padding: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Question: ", data.id] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: data.question }) }), data.context ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Context: ", data.context] }) })) : null, (data.kind === 'choice' || data.kind === 'multi') && data.choices ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: data.kind === 'choice' ? 'Pick one:' : 'Pick any (comma-separate):' }), data.choices.map((c, i) => (_jsxs(Text, { children: ["[", i + 1, "] ", c.label] }, c.value)))] })) : null] }));
11
12
  }
@@ -0,0 +1,24 @@
1
+ import { useEffect } from 'react';
2
+ import { resetTtyStdin } from '../../repl/reset-tty-stdin.js';
3
+ /**
4
+ * Reclaim raw-mode stdin when an Ink modal mounts (2026-06-08).
5
+ *
6
+ * `@inquirer/prompts` toggles the terminal's raw mode (and pauses stdin)
7
+ * out from under Ink while it runs. When it releases, Ink's INTERNAL
8
+ * bookkeeping still thinks raw mode is on, so it does not re-assert it — a
9
+ * card mounted afterwards receives no keypresses. Re-syncing the OS state
10
+ * to what Ink expects on mount fixes it. Direct `process.stdin` calls (not
11
+ * Ink's ref-counted setRawMode) because Ink's count is the stale part.
12
+ *
13
+ * Moved here from the retired v1 question modal (piece 2 / Q1b) so AskCard
14
+ * and ApprovalModal share it; the modal file was deleted in Q2.
15
+ *
16
+ * Review I2 (2026-09-27): `setRawMode(true)` + `resume()` alone is the pair
17
+ * the live-fix pty evidence showed does NOT revive Windows console stdin
18
+ * after an inquirer prompt. Use the full reset cycle (no-op off a TTY).
19
+ */
20
+ export function useReclaimRawStdin() {
21
+ useEffect(() => {
22
+ resetTtyStdin(process.stdin);
23
+ }, []);
24
+ }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ import { roleProps } from '../role-props.js';
4
+ import { shouldUseUnicodeBoxChars } from '../../renderer/tty.js';
5
+ import { truncateToWidth } from '../../renderer/ui-width.js';
6
+ /** Spec §8 table, in order. */
7
+ export const SHORTCUT_ROWS = [
8
+ ['/', 'commands'],
9
+ ['@', 'files'],
10
+ ['Ctrl+R', 'history'],
11
+ ['Ctrl+T', 'plan'],
12
+ ['Shift+Tab', 'mode'],
13
+ ['Esc Esc', 'rewind'],
14
+ ['Ctrl+V', 'image'],
15
+ ['\\', 'new line'],
16
+ ];
17
+ export const SHORTCUTS_NOTE = "A message can't start with ? — type it after any other character.";
18
+ const KEY_COL = 11;
19
+ /** The `?` sheet (card-slot occupant 'shortcuts'). Esc closes. */
20
+ export function ShortcutsSheet({ width, onClose }) {
21
+ useInput((input, key) => { if (key.escape || (key.ctrl && input === 'c'))
22
+ onClose(); });
23
+ const inner = Math.max(8, width - 4);
24
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: shouldUseUnicodeBoxChars() ? 'round' : 'classic', borderColor: roleProps('frame').color, paddingX: 1, width: width, children: [_jsx(Text, { bold: true, children: "Shortcuts" }), SHORTCUT_ROWS.map(([key, action]) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap'), children: key.padEnd(KEY_COL) }), truncateToWidth(action, Math.max(1, inner - KEY_COL))] }, key))), _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: truncateToWidth(SHORTCUTS_NOTE, inner) }), _jsx(Text, { ...roleProps('dim'), children: "Esc close" })] }));
25
+ }
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { roleProps } from '../role-props.js';
3
4
  export function StackFrames({ data }) {
4
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Stack Frames" }), data.map((f) => (_jsxs(Box, { flexDirection: "column", marginTop: f.frame === 0 ? 0 : 1, children: [_jsx(Box, { children: _jsxs(Text, { color: f.type === 'error' ? 'red' : 'gray', children: ['#' + String(f.frame).padStart(2, '0'), " ", f.program, ":", f.line] }) }), _jsx(Box, { marginLeft: 5, children: _jsx(Text, { color: f.type === 'error' ? 'white' : 'gray', dimColor: f.type === 'context', children: f.code }) })] }, f.frame)))] }));
5
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: roleProps('bad').color, paddingX: 1, children: [_jsx(Text, { ...roleProps('bad', { bold: true }), children: "Stack Frames" }), data.map((f) => (_jsxs(Box, { flexDirection: "column", marginTop: f.frame === 0 ? 0 : 1, children: [_jsx(Box, { children: _jsxs(Text, { ...roleProps(f.type === 'error' ? 'bad' : 'dim'), children: ['#' + String(f.frame).padStart(2, '0'), " ", f.program, ":", f.line] }) }), _jsx(Box, { marginLeft: 5, children: _jsx(Text, { ...(f.type === 'error' ? {} : roleProps('dim')), dimColor: f.type === 'context', children: f.code }) })] }, f.frame)))] }));
5
6
  }
@@ -1,6 +1,8 @@
1
- import chalk from 'chalk';
1
+ import { formatAttention } from './renderer/notice-shape.js';
2
2
  import { loadConfig } from './config/loader.js';
3
3
  import { CLI_VERSION } from './lib/version.js';
4
+ import { getStore } from './auth/api-key.js';
5
+ import { brandSettled } from './renderer/brand-settled.js';
4
6
  function compareVersions(a, b) {
5
7
  const pa = a.split('.').map(Number);
6
8
  const pb = b.split('.').map(Number);
@@ -12,18 +14,47 @@ function compareVersions(a, b) {
12
14
  }
13
15
  return 0;
14
16
  }
17
+ /**
18
+ * Minimum-version check (fix round 3, item 11c). The proxy has no
19
+ * unauthenticated endpoint that carries the minimum CLI version (/health
20
+ * reports the proxy's own version only), and it never sets an
21
+ * `x-skill-min-cli-version` header: `min_cli_version` is a field of the
22
+ * AUTHENTICATED `GET /v1/skills/:name` JSON body. The old unauthenticated
23
+ * HEAD always got a 401, so the warning could never fire. Now: signed in →
24
+ * GET with the bearer and read the body field (the header is still honoured
25
+ * should the proxy add it); not signed in → skip (a 401 tells us nothing).
26
+ * The warning goes to stderr so one-shot stdout stays the answer only.
27
+ */
15
28
  export async function checkForUpgrade() {
16
- const cfg = await loadConfig();
17
29
  try {
18
- const r = await fetch(`${cfg.proxy_url}/v1/skills/abap-radar`, { method: 'HEAD', signal: AbortSignal.timeout(3000) });
19
- const minCli = r.headers.get('x-skill-min-cli-version');
30
+ const cfg = await loadConfig();
31
+ const key = await (await getStore()).get();
32
+ if (!key)
33
+ return;
34
+ const r = await fetch(`${cfg.proxy_url}/v1/skills/abap-radar`, {
35
+ headers: { Authorization: `Bearer ${key}` },
36
+ signal: AbortSignal.timeout(3000),
37
+ });
38
+ if (!r.ok)
39
+ return;
40
+ let minCli = r.headers.get('x-skill-min-cli-version');
41
+ if (!minCli) {
42
+ const body = (await r.json().catch(() => null));
43
+ minCli = typeof body?.min_cli_version === 'string' ? body.min_cli_version : null;
44
+ }
20
45
  const current = CLI_VERSION;
21
- if (minCli && compareVersions(current, minCli) < 0) {
22
- console.log(chalk.yellow.bold(`\n⚠ Your cspeach (${current}) is older than the minimum required (${minCli}).`));
23
- console.log(chalk.yellow(` Run: cspeach --upgrade\n`));
46
+ if (minCli && /^\d+(\.\d+){0,2}$/.test(minCli) && compareVersions(current, minCli) < 0) {
47
+ // Piece 2 §10: one "!" line + Next: (minCli is a server value — formatAttention sanitises it).
48
+ // Never mid-line: wait for the early panels brand line to end (resolved
49
+ // at once when none is open — legacy, one-shot, piped).
50
+ await brandSettled();
51
+ console.error('\n' + formatAttention({
52
+ text: `This CSPeach (${current}) is older than the minimum (${minCli})`,
53
+ next: 'cspeach --upgrade',
54
+ }).trimEnd());
24
55
  }
25
56
  }
26
57
  catch {
27
- // Offline or proxy down — don't block startup.
58
+ // Offline, keychain unavailable or proxy down — never block startup.
28
59
  }
29
60
  }