@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
@@ -1,10 +1,11 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
+ import { riskReasonText, shownRiskReasons } from '../approvals/risk-floor.js';
4
5
  import { verbLine, viewAffordance, toSourceLines } from '../approvals/op-labels.js';
5
6
  // BUG-1 (2026-07-05 live smoke): the interactive approval modal mounted
6
7
  // DEAD — every keypress ignored — when it appeared after a guarded
7
- // /abap-plan turn. Root cause is identical to the one AskQuestionModal
8
+ // /abap-plan turn. Root cause is identical to the one AskCard
8
9
  // already solves: the approval plan-gate / run-lease takeover uses
9
10
  // `@inquirer/prompts`, which toggles the terminal's raw mode (and pauses
10
11
  // stdin) out from under Ink. When it releases, Ink's INTERNAL ref-count
@@ -12,19 +13,25 @@ import { verbLine, viewAffordance, toSourceLines } from '../approvals/op-labels.
12
13
  // mounted afterwards receives no keypresses. Reclaiming raw stdin on mount
13
14
  // (direct process.stdin calls, because Ink's count is the thing that's
14
15
  // stale) re-syncs the OS state to what Ink's useInput expects.
15
- import { useReclaimRawStdin } from './widgets/ask-question-modal.js';
16
- const LABEL_MAP = {
17
- delete_op: 'delete op detected',
18
- prod_namespace: 'PROD namespace',
19
- shared_transport: 'transport shared by 3+ devs',
20
- atc_failed_24h: 'ATC failed in last 24h',
21
- non_prod_override: 'auto-approve policy: never',
22
- escalated_in_session: '>5 changes this session',
23
- };
16
+ import { useReclaimRawStdin } from './widgets/reclaim-raw-stdin.js';
17
+ import { getLook } from '../renderer/look.js';
18
+ import { roleProps, inkChip, legacyInkColor } from './role-props.js';
19
+ // Legacy look: today's literal names (bytes unchanged while colour is on).
24
20
  function riskColor(level) {
25
21
  return level === 'high' ? 'red' : level === 'medium' ? 'yellow' : 'green';
26
22
  }
27
- export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel }) {
23
+ // Panels look: the same meaning as theme roles. Without colour the risk still
24
+ // reads from the "Risk: <level>" text.
25
+ function riskRole(level) {
26
+ return level === 'high' ? 'bad' : level === 'medium' ? 'warn' : 'ok';
27
+ }
28
+ /** Review-view line colour: + added, - removed, rest dim. */
29
+ function diffLineColor(line, panels) {
30
+ if (panels)
31
+ return roleProps(line.startsWith('+') ? 'ok' : line.startsWith('-') ? 'bad' : 'dim').color;
32
+ return legacyInkColor(line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray');
33
+ }
34
+ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSkip, onDiff, onCancel, onInterruptTurn }) {
28
35
  // BUG-1 — re-assert raw mode on mount so keypresses reach useInput even
29
36
  // when an inquirer gate ran earlier in the turn (see import block).
30
37
  useReclaimRawStdin();
@@ -52,6 +59,15 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
52
59
  onDiff();
53
60
  return;
54
61
  }
62
+ // `c` is the [c] Cancel turn key; Ctrl+C (Ink: input 'c' + key.ctrl) and
63
+ // Esc cancel too — spelled out, not by coincidence (review M6).
64
+ // Review r7 M-7 — Ctrl+C then stops the turn too; Esc / [c] only resolve
65
+ // the card with the cancel-turn result, as before.
66
+ if (key.ctrl && input === 'c') {
67
+ onCancel();
68
+ onInterruptTurn?.();
69
+ return;
70
+ }
55
71
  if (input === 'c' || key.escape) {
56
72
  onCancel();
57
73
  return;
@@ -60,9 +76,11 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
60
76
  // Review sub-view — a diff (modify) or a source dump (create/delete).
61
77
  if (viewing && aff) {
62
78
  const isDiff = aff.key === 'd';
63
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, children: [isDiff ? 'Diff' : aff.label, " \u2014 Esc to return"] }), change.diff ? (isDiff ? (change.diff.split('\n').map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: line.startsWith('+') ? 'green' : line.startsWith('-') ? 'red' : 'gray', children: line }, i)))) : (toSourceLines(change.diff).map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: "gray", children: [String(i + 1).padStart(4), " "] }), line] }, i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" }))] }));
79
+ const panels = getLook() === 'panels';
80
+ const quiet = panels ? roleProps('dim').color : legacyInkColor('gray');
81
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: panels ? roleProps('frame').color : quiet, paddingX: 1, children: [_jsxs(Text, { bold: true, children: [isDiff ? 'Diff' : aff.label, " \u2014 Esc to return"] }), change.diff ? (isDiff ? (change.diff.split('\n').map((line, i) => (_jsx(Text, { wrap: "truncate-end", color: diffLineColor(line, panels), children: line }, i)))) : (toSourceLines(change.diff).map((line, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: quiet, children: [String(i + 1).padStart(4), " "] }), line] }, i))))) : (_jsx(Text, { dimColor: true, children: "(no source provided by the model)" }))] }));
64
82
  }
65
- const raisedLabel = effectiveRisk.raisedBy.map((r) => LABEL_MAP[r] ?? r).join(', ');
83
+ const raisedLabel = shownRiskReasons(effectiveRisk.raisedBy).map(riskReasonText).join('; ');
66
84
  // Meta line: Package · Transport — each segment only when genuinely known.
67
85
  // The handler resolves `transport` per op (see op-labels displayTransport:
68
86
  // create-family may fall back to the session transport because the create
@@ -76,11 +94,19 @@ export function ApprovalModal({ change, effectiveRisk, transport, onApply, onSki
76
94
  metaSegs.push(`Transport: ${transport}`);
77
95
  // Op-aware verb line ("Create table ZMNT_NOTE"); delete gets the hot accent.
78
96
  const verb = verbLine(change);
79
- const verbColor = change.op === 'delete' ? 'red' : undefined;
97
+ const verbColor = change.op === 'delete' ? legacyInkColor('red') : undefined;
80
98
  // Keys row — one line, human words, truncate-end (Wave-1 no-wrap rule).
81
99
  const keys = ['[a] Approve'];
82
100
  if (aff)
83
101
  keys.push(`[${aff.key}] ${aff.label}`);
84
102
  keys.push('[s] Skip', '[c] Cancel turn');
85
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: riskColor(effectiveRisk.level), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), _jsxs(Text, { color: riskColor(effectiveRisk.level), wrap: "truncate-end", children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised from low: ${raisedLabel})` : ''] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
103
+ // Panels look — same content, keys and risk colour; restyled only. The
104
+ // approval runs BEFORE snapshot/write/syntax check, so the card claims no
105
+ // step has run. Legacy return below stays byte-for-byte unchanged.
106
+ if (getLook() === 'panels') {
107
+ const risk = roleProps(riskRole(effectiveRisk.level));
108
+ const tag = inkChip('APPROVAL');
109
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: risk.color, paddingX: 2, paddingY: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...tag.props, children: tag.text }), _jsx(Text, { children: ' ' }), _jsx(Text, { ...(change.op === 'delete' ? roleProps('bad') : {}), bold: true, children: verb }), change.op === 'create' ? _jsx(Text, { ...roleProps('dim'), children: " (new object)" }) : null] }), metaSegs.length > 0 && _jsx(Text, { ...roleProps('dim'), wrap: "truncate-end", children: metaSegs.join(' · ') }), _jsxs(Text, { ...risk, wrap: "truncate-end", children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised: ${raisedLabel})` : ''] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { ...roleProps('sap', { bold: true }), children: keys[0] }), _jsx(Text, { children: ' ' + keys.slice(1).join(' ') })] }) })] }));
110
+ }
111
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: legacyInkColor(riskColor(effectiveRisk.level)), paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Write approval" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: verbColor, children: verb }), change.op === 'create' ? _jsx(Text, { dimColor: true, children: " (new object)" }) : null] }) }), metaSegs.length > 0 && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: metaSegs.join(' · ') })), _jsxs(Text, { color: legacyInkColor(riskColor(effectiveRisk.level)), wrap: "truncate-end", children: ["Risk: ", effectiveRisk.level, raisedLabel ? ` (raised: ${raisedLabel})` : ''] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { wrap: "truncate-end", children: keys.join(' ') }) })] }));
86
112
  }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The words a person picked — for the scrollback trace (tools/ask-question.ts)
3
+ * and the form's Review step (widgets/ask-card.tsx). Option VALUES are
4
+ * machine ids, so show their LABELS; a multi answer is comma-joined values.
5
+ * Typed answers (custom escape, free text) are shown exactly as typed.
6
+ */
7
+ export function answerDisplayText(answer, options, custom = false) {
8
+ if (custom || options.length === 0)
9
+ return answer;
10
+ const whole = options.find((o) => o.value === answer);
11
+ if (whole)
12
+ return whole.label;
13
+ return answer
14
+ .split(',')
15
+ .map((v) => options.find((o) => o.value === v)?.label ?? v)
16
+ .join(', ');
17
+ }
package/dist/ui/body.js CHANGED
@@ -1,9 +1,13 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React, { useReducer, useEffect } from 'react';
3
3
  import { Text, Static } from 'ink';
4
4
  import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
5
5
  import { WIDGET_CONTROL } from './widget-control.js';
6
6
  import { formatNoticeLine } from '../renderer/notices.js';
7
+ import { getLook } from '../renderer/look.js';
8
+ import { getGlyphs } from '../renderer/glyphs.js';
9
+ import { roleProps } from './role-props.js';
10
+ import { attachFlow, InkNodeCommitter } from '../renderer/transcript-flow.js';
7
11
  /**
8
12
  * Frame interval for batching streaming chunks before dispatching to the
9
13
  * reducer (W2.2 — 2026-05-18). Ink already throttles its stdout writes to
@@ -131,7 +135,7 @@ function renderNode(n) {
131
135
  const Comp = WIDGET_COMPONENTS[n.widgetType];
132
136
  return _jsx(Comp, { data: n.data }, n.id);
133
137
  }
134
- return _jsx(Text, { color: "yellow", children: "\u26A0 widget render error" }, n.id);
138
+ return _jsxs(Text, { ...roleProps('warn'), children: [getGlyphs().attention, " This widget could not be shown"] }, n.id);
135
139
  }
136
140
  /**
137
141
  * Inline-mode Body (Phase D6, 2026-05-17) — back to CC's architecture
@@ -161,16 +165,31 @@ export const Body = React.memo(function Body({ chunkEmitter }) {
161
165
  // pays nothing; dense stream (e.g. 5000 chunks during code-gen) sees
162
166
  // ~60 reducer calls instead of 5000. dispose() flushes pending text
163
167
  // synchronously on unmount so end-of-stream output isn't dropped.
164
- const batcher = createChunkBatcher((buffered) => dispatch({ chunk: buffered }));
165
- const handler = (chunk) => batcher.push(chunk);
168
+ // Panels look (polish P1): spacing is normalised as chunks arrive
169
+ // (transcript-flow.ts) and each batch is committed so a node renders like
170
+ // the same bytes on a terminal (no extra blank line per node). Legacy:
171
+ // untouched pass-through, as before.
172
+ const flow = attachFlow(chunkEmitter);
173
+ const committer = new InkNodeCommitter();
174
+ const batcher = createChunkBatcher((buffered) => {
175
+ if (getLook() !== 'panels') {
176
+ dispatch({ chunk: buffered });
177
+ return;
178
+ }
179
+ const node = committer.commit(buffered);
180
+ if (node !== null)
181
+ dispatch({ chunk: node });
182
+ });
183
+ const feed = (s) => (getLook() === 'panels' ? flow.feed(s) : s);
184
+ const handler = (chunk) => batcher.push(feed(chunk));
166
185
  // B2 fix (2026-06-12) — consume the 'warn'/'info' notice channel.
167
186
  // Tool handlers (transport-resolution Rule 9 notices, write-tool
168
187
  // snapshot lines) emit these levels on the same emitter; before this
169
188
  // they were silent no-ops in the live Ink REPL. Notices are formatted
170
189
  // to a single line and pushed through the SAME batcher so their order
171
190
  // relative to surrounding chunk text is preserved.
172
- const warnHandler = (msg) => batcher.push(formatNoticeLine('warn', msg));
173
- const infoHandler = (msg) => batcher.push(formatNoticeLine('info', msg));
191
+ const warnHandler = (msg) => batcher.push(feed(formatNoticeLine('warn', msg)));
192
+ const infoHandler = (msg) => batcher.push(feed(formatNoticeLine('info', msg)));
174
193
  chunkEmitter.on('chunk', handler);
175
194
  chunkEmitter.on('warn', warnHandler);
176
195
  chunkEmitter.on('info', infoHandler);
@@ -0,0 +1,98 @@
1
+ // src/ui/card-slot.ts
2
+ //
3
+ // Card-slot request API (CLI look piece 2, spec §1.6) — the only way anything
4
+ // asks the user something under Ink. It performs the input-focus claim itself:
5
+ // suspendActiveSpinner() + turnStatusEmitter.pause(), both resumed on close — the
6
+ // pattern that fixed the doubled-card / Enter-leak / 10-minute-wedge bug
7
+ // (was duplicated in repl/safety-confirm.ts and commands/plan-chain.ts).
8
+ // App mounts ONE card at a time (ui/live-area.ts pickSlotOccupant).
9
+ import { EventEmitter } from 'node:events';
10
+ import { suspendActiveSpinner } from '../renderer/tool-widget.js';
11
+ import { turnStatusEmitter } from './turn-status-emitter.js';
12
+ /**
13
+ * Claim exclusive input focus for the duration of `fn`: pause the in-place
14
+ * tool spinner and freeze the ticking status row so only the card repaints.
15
+ * Both come back when the card closes (piece 2 seam 5: the tool is usually
16
+ * still running after the card, so the classic spinner must resume). A
17
+ * single claim behaves as before for the row (incl. resume() when the row
18
+ * was active, even if it was already paused before the claim). Overlapping
19
+ * claims share one pause: the row and the spinner resume when the last of
20
+ * them releases.
21
+ */
22
+ let claimDepth = 0;
23
+ let resumeOnLastRelease = false;
24
+ /** Seam 5: one spinner resume per live claim of this turn, all fired on the last live release. */
25
+ let spinnerResumes = [];
26
+ /** The top-level turn the count above belongs to (turnStatusEmitter.turnGeneration). */
27
+ let claimTurn = -1;
28
+ export async function withFocusClaim(fn) {
29
+ // C2 hardening: the count is per top-level turn. A new turn starts it
30
+ // afresh, so a claim left unsettled by an earlier turn (Ctrl+C mid-card)
31
+ // can never hold the count above 0 and suppress this turn's resume.
32
+ const turn = turnStatusEmitter.turnGeneration();
33
+ if (turn !== claimTurn) {
34
+ claimTurn = turn;
35
+ claimDepth = 0;
36
+ resumeOnLastRelease = false;
37
+ // A stale claim's suspend is dropped, never resumed: its tool/turn is over.
38
+ spinnerResumes = [];
39
+ }
40
+ // Seam 5: SUSPEND, not stop. Every claim suspends (counted in the spinner),
41
+ // so nothing paints under an open card; its one-shot resume is released
42
+ // with the last live claim below.
43
+ spinnerResumes.push(suspendActiveSpinner());
44
+ // Refcounted (C1 fix round 1): a replaced card resolves its claim while the
45
+ // replacing card is still open. Only the LAST release may resume the row,
46
+ // otherwise it would tick again under the open card.
47
+ if (turnStatusEmitter.snapshot().active) {
48
+ turnStatusEmitter.pause();
49
+ resumeOnLastRelease = true;
50
+ }
51
+ claimDepth += 1;
52
+ try {
53
+ return await fn();
54
+ }
55
+ finally {
56
+ // A stale claim (its turn is over) settles without touching any count
57
+ // and never resumes: not under a later turn's open card, and not under a
58
+ // loop-level pause (ask_question, the save hook) taken before that turn
59
+ // made its first claim (C2 fix round 1).
60
+ const live = turn === claimTurn && turn === turnStatusEmitter.turnGeneration();
61
+ if (live)
62
+ claimDepth -= 1;
63
+ if (live && claimDepth === 0 && resumeOnLastRelease) {
64
+ resumeOnLastRelease = false;
65
+ // resume() is a no-op if the turn already ended (Ctrl+C mid-card).
66
+ turnStatusEmitter.resume();
67
+ }
68
+ if (live && claimDepth === 0 && spinnerResumes.length > 0) {
69
+ const resumes = spinnerResumes;
70
+ spinnerResumes = [];
71
+ // No-op when there was no spinner, when the tool stopped it meanwhile,
72
+ // or while another owner (an inquirer prompt) still holds it.
73
+ for (const resumeSpinner of resumes)
74
+ resumeSpinner();
75
+ }
76
+ }
77
+ }
78
+ class CardSlotEmitter extends EventEmitter {
79
+ /** No listener (classic / headless / App not mounted) → cancelled, nothing shown, no claim. */
80
+ requestConfirm(req) {
81
+ if (this.listenerCount('confirm') === 0) {
82
+ return Promise.resolve({ value: null, cancelled: true });
83
+ }
84
+ return withFocusClaim(() => new Promise((resolve) => {
85
+ this.emit('confirm', { ...req, resolve });
86
+ }));
87
+ }
88
+ on(event, listener) {
89
+ return super.on(event, listener);
90
+ }
91
+ off(event, listener) {
92
+ return super.off(event, listener);
93
+ }
94
+ listenerCount(event) {
95
+ return super.listenerCount(event);
96
+ }
97
+ }
98
+ export const cardSlotEmitter = new CardSlotEmitter();
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { formatChecklist } from '../renderer/checklist-format.js';
4
+ export function Checklist(p) {
5
+ const lines = formatChecklist(p.todos, p);
6
+ if (lines.length === 0)
7
+ return null;
8
+ return (_jsx(Box, { flexDirection: "column", children: lines.map((l, i) => _jsx(Text, { wrap: "truncate-end", children: l }, i)) }));
9
+ }
@@ -1,22 +1,25 @@
1
1
  import { select } from '@inquirer/prompts';
2
2
  import { PICK_CANCELLED } from './skill-picker.js';
3
+ import { truncateToWidth, uiWidth } from '../renderer/ui-width.js';
4
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
3
5
  const CANCEL_VALUE = '__cancel__';
4
6
  /**
5
7
  * Classic-mode coaching picker via inquirer's select prompt. Each choice shows
6
- * a single-line `name` (skill + confidence %) in the list; the "Does:" and
7
- * "Pick if:" sub-lines appear under the highlighted entry via `description`.
8
+ * a single-line `name` (skill + what it does) in the list; the "Pick if:"
9
+ * sub-line appears under the highlighted entry via `description`.
8
10
  * Cancel entry always present at the bottom.
9
11
  *
10
12
  * Ink-mode has a separate implementation — see src/ui/widgets/coaching-picker.tsx.
11
13
  */
12
14
  export async function coachingPickerClassic(params) {
13
15
  const choices = params.options.map((opt) => ({
14
- name: `/${opt.skill} · ${Math.round(opt.confidence * 100)}%`,
16
+ // Piece 2 §3: the skill plus what it does; the confidence % is dropped.
17
+ name: truncateToWidth(sanitizeControlChars(`/${opt.skill} · ${opt.whatItDoes}`), Math.max(20, uiWidth() - 4)),
15
18
  value: opt.skill,
16
- description: ` Does: ${opt.whatItDoes}\n Pick if: ${opt.whenToPick}`,
19
+ description: ` Pick if: ${opt.whenToPick}`,
17
20
  }));
18
21
  choices.push({
19
- name: '↩ cancel (go back to prompt)',
22
+ name: 'Cancel · back to the prompt',
20
23
  value: CANCEL_VALUE,
21
24
  });
22
25
  const message = `I'm not sure which skill fits for: "${params.prompt.slice(0, 50)}${params.prompt.length > 50 ? '...' : ''}". Pick one:`;
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useMemo } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import TextInput from 'ink-text-input';
5
+ import { roleProps, selectedProps } from './role-props.js';
5
6
  import { ALL_SLASH_ENTRIES } from '../repl/slash-picker.js';
6
7
  const VISIBLE_ROWS = 8;
7
8
  // Longest command is /abap-transport-analysis (24) — fixed column keeps
@@ -39,8 +40,9 @@ export function CommandPalette({ initialQuery, onSelect, onCancel }) {
39
40
  const [cursor, setCursor] = useState(0);
40
41
  const results = useMemo(() => rankCommandMatches(query), [query]);
41
42
  useEffect(() => { setCursor(0); }, [query]);
42
- useInput((_input, key) => {
43
- if (key.escape) {
43
+ useInput((input, key) => {
44
+ // Ctrl+C cancels like Esc (fix round 3, item 4).
45
+ if (key.escape || (key.ctrl && input === 'c')) {
44
46
  onCancel();
45
47
  return;
46
48
  }
@@ -62,9 +64,9 @@ export function CommandPalette({ initialQuery, onSelect, onCancel }) {
62
64
  const windowStart = Math.max(0, Math.min(cursor - 3, results.length - VISIBLE_ROWS));
63
65
  const visible = results.slice(windowStart, windowStart + VISIBLE_ROWS);
64
66
  const below = Math.max(0, results.length - (windowStart + VISIBLE_ROWS));
65
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "/ " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to filter commands" })] }), results.length === 0 && (_jsx(Text, { dimColor: true, children: " no matching command \u2014 edit the filter or Esc to cancel" })), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((e, i) => {
67
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { ...roleProps('sap'), children: "/ " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to filter commands" })] }), results.length === 0 && (_jsx(Text, { dimColor: true, children: " no matching command \u2014 edit the filter or Esc to cancel" })), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((e, i) => {
66
68
  const idx = windowStart + i;
67
69
  const sel = idx === cursor;
68
- return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: sel ? 'cyan' : undefined, bold: sel, children: [sel ? '▸ ' : ' ', e.name.padEnd(NAME_COL)] }), _jsx(Text, { dimColor: true, children: e.description })] }, e.name));
70
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { ...(sel ? selectedProps() : {}), children: [sel ? '▸ ' : ' ', e.name.padEnd(NAME_COL)] }), _jsx(Text, { dimColor: true, children: e.description })] }, e.name));
69
71
  }), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" })] }));
70
72
  }
@@ -0,0 +1,18 @@
1
+ // src/ui/confirm-request.ts
2
+ //
3
+ // The one way product code asks a confirm under Ink (piece 2 §5): ask through
4
+ // the card slot, then leave the one-line decision record in scrollback (§1.5).
5
+ // F4's cardSlotEmitter.requestConfirm already runs inside withFocusClaim
6
+ // (spinner stopped, status row paused, resumed after — the 2026-06-13
7
+ // doubled-card / Enter-leak / 10-minute-wedge fix), so the focus is claimed
8
+ // exactly once there; askConfirm must not wrap it again (spec §1.6).
9
+ import { cardSlotEmitter } from './card-slot.js';
10
+ import { emitTrace, formatDecisionTrace } from '../renderer/trace.js';
11
+ export async function askConfirm(req, opts = {}) {
12
+ const res = await cardSlotEmitter.requestConfirm(req);
13
+ if (opts.trace !== false) {
14
+ const picked = res.value == null ? undefined : req.choices.find((c) => c.value === res.value);
15
+ emitTrace(formatDecisionTrace(opts.traceLabel ?? req.title, picked?.label ?? 'Cancelled'));
16
+ }
17
+ return res;
18
+ }
@@ -15,6 +15,7 @@
15
15
  // classic ones and an ANSI escape can never be split by the cut.
16
16
  import chalk from 'chalk';
17
17
  import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
18
+ import { paint } from '../renderer/theme.js';
18
19
  import { buildContextReport } from '../session/context-report.js';
19
20
  import { readCostLog } from '../cost/cost-log.js';
20
21
  /** Bars never exceed this many columns (Wave-1 proportional-bar cap). */
@@ -84,7 +85,7 @@ export function renderContextGrid(report, opts = {}) {
84
85
  for (const h of report.hints) {
85
86
  // Same order as the header: plain-truncate, THEN color.
86
87
  const text = truncateEnd(`${arrow} ${h}`, width);
87
- lines.push(color ? chalk.dim(text) : text);
88
+ lines.push(color ? paint('dim', text) : text);
88
89
  }
89
90
  }
90
91
  return lines;
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useMemo } from 'react';
3
3
  import { Box, Text, useInput } from 'ink';
4
4
  import TextInput from 'ink-text-input';
5
+ import { roleProps, selectedProps } from './role-props.js';
5
6
  import { filterFileChoices } from '../repl/at-picker.js';
6
7
  const VISIBLE_ROWS = 8;
7
8
  /**
@@ -15,8 +16,9 @@ export function FilePalette({ files, initialQuery, onSelect, onCancel }) {
15
16
  const [cursor, setCursor] = useState(0);
16
17
  const results = useMemo(() => filterFileChoices(query, files), [query, files]);
17
18
  useEffect(() => { setCursor(0); }, [query]);
18
- useInput((_input, key) => {
19
- if (key.escape) {
19
+ useInput((input, key) => {
20
+ // Ctrl+C cancels like Esc (fix round 3, item 4).
21
+ if (key.escape || (key.ctrl && input === 'c')) {
20
22
  onCancel();
21
23
  return;
22
24
  }
@@ -36,9 +38,9 @@ export function FilePalette({ files, initialQuery, onSelect, onCancel }) {
36
38
  const windowStart = Math.max(0, Math.min(cursor - 3, results.length - VISIBLE_ROWS));
37
39
  const visible = results.slice(windowStart, windowStart + VISIBLE_ROWS);
38
40
  const below = Math.max(0, results.length - (windowStart + VISIBLE_ROWS));
39
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "@ " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to filter workspace files" })] }), results.length === 0 && (_jsx(Text, { dimColor: true, children: " no matching file \u2014 edit the filter or Esc to cancel" })), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((f, i) => {
41
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { ...roleProps('sap'), children: "@ " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to filter workspace files" })] }), results.length === 0 && (_jsx(Text, { dimColor: true, children: " no matching file \u2014 edit the filter or Esc to cancel" })), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((f, i) => {
40
42
  const idx = windowStart + i;
41
43
  const sel = idx === cursor;
42
- return (_jsx(Text, { wrap: "truncate-end", children: _jsxs(Text, { color: sel ? 'cyan' : undefined, bold: sel, children: [sel ? '▸ ' : ' ', f.filename] }) }, f.filename));
44
+ return (_jsx(Text, { wrap: "truncate-end", children: _jsxs(Text, { ...(sel ? selectedProps() : {}), children: [sel ? '▸ ' : ' ', f.filename] }) }, f.filename));
43
45
  }), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" })] }));
44
46
  }
@@ -0,0 +1,19 @@
1
+ // src/ui/files-card-emitter.ts
2
+ //
3
+ // Piece 2 §6.4 — the `/files` search-and-list card's request channel (card
4
+ // slot occupant 'files'). Same no-listener contract as askQuestionEmitter and
5
+ // cardSlotEmitter: resolve cancelled at once so nothing ever waits on a UI
6
+ // that is not mounted.
7
+ import { EventEmitter } from 'node:events';
8
+ class FilesCardEmitter extends EventEmitter {
9
+ requestPick(req) {
10
+ return new Promise((resolve) => {
11
+ if (this.listenerCount('files') === 0) {
12
+ resolve({ value: null, cancelled: true });
13
+ return;
14
+ }
15
+ this.emit('files', { ...req, resolve });
16
+ });
17
+ }
18
+ }
19
+ export const filesCardEmitter = new FilesCardEmitter();
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text, useStdout } from 'ink';
3
+ import { useSapState } from './sap-state-store.js';
4
+ import { formatFooterLine, footerStateFrom } from '../renderer/footer-line.js';
5
+ import { inputBoxWidth } from './footer.js';
6
+ /**
7
+ * The one footer line under the input box (piece 2, spec §8) — replaces the
8
+ * StatusLine. Same width as the input box so the two align. Single truncating
9
+ * <Text>: a dynamic-frame row must never soft-wrap.
10
+ */
11
+ export function FooterLine() {
12
+ const s = useSapState();
13
+ const { stdout } = useStdout();
14
+ const width = inputBoxWidth(stdout?.columns || 80);
15
+ return _jsx(Text, { wrap: "truncate-end", children: formatFooterLine(footerStateFrom(s), width) });
16
+ }