@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
@@ -7,9 +7,8 @@
7
7
  *
8
8
  * Three presentation surfaces, decided per call (headless first):
9
9
  * - headless → fail-fast decline, no prompt (B5)
10
- * - Ink → askQuestionEmitter choice modal (UX-5 review I1) —
11
- * inquirer must NEVER run under Ink: it fights the
12
- * React frame for stdin and paints over it
10
+ * - Ink → confirm card in the card slot via askConfirm
11
+ * (piece 2 §5) — inquirer must NEVER run under Ink
13
12
  * - classic → inquirer select via withInquirer (unchanged)
14
13
  */
15
14
  import chalk from 'chalk';
@@ -17,9 +16,11 @@ import { select } from '@inquirer/prompts';
17
16
  import { withInquirer } from './inquirer-guard.js';
18
17
  import { inquirerTheme } from './inquirer-theme.js';
19
18
  import { isHeadless, shouldUseInk } from '../renderer/tty.js';
20
- import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
21
- import { clearActiveSpinner } from '../renderer/tool-widget.js';
22
- import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
19
+ import { askConfirm } from '../ui/confirm-request.js';
20
+ import { emitTrace } from '../renderer/trace.js';
21
+ import { getGlyphs } from '../renderer/glyphs.js';
22
+ import { clean, hasToolLabel, toolActivityLabel, toolLabel } from '../renderer/tool-labels.js';
23
+ import { cardSlotEmitter, withFocusClaim } from '../ui/card-slot.js';
23
24
  const RULE_LABEL = {
24
25
  RULE_7_SNAPSHOT_BEFORE_WRITE: 'RULE 7 — snapshot before write',
25
26
  RULE_7A_USE_UPDATE_METHOD: 'RULE 7a — use sap_update_method (not sap_set_source)',
@@ -27,7 +28,98 @@ const RULE_LABEL = {
27
28
  RULE_9_TRANSPORT_RELEASE: 'RULE 9 — transport release (irreversible)',
28
29
  RULE_9_TRANSPORT_ISOLATION: 'RULE 9 — transport isolation',
29
30
  RULE_10_VERIFY_AFTER_WRITE: 'RULE 10 — verify after write',
31
+ CREDENTIAL_HANDOVER: 'SAP login handed to a local process',
30
32
  };
33
+ /** Piece 2 §5 — plain-words heading: what could go wrong. No rule codes, no tool names. */
34
+ export const SAFETY_HEADING = {
35
+ RULE_7_SNAPSHOT_BEFORE_WRITE: 'This replaces an object in SAP',
36
+ RULE_7A_USE_UPDATE_METHOD: 'A full rewrite could change code you did not ask to change',
37
+ RULE_8_BATCH_PLAN: 'Another SAP change in this turn, without an approved plan',
38
+ RULE_9_TRANSPORT_RELEASE: 'Releasing a transport cannot be undone',
39
+ RULE_9_TRANSPORT_ISOLATION: 'This change may land in a transport shared with other work',
40
+ RULE_10_VERIFY_AFTER_WRITE: 'This change is not proven until it is checked',
41
+ CREDENTIAL_HANDOVER: 'Your SAP login will be handed to a local program',
42
+ };
43
+ /** Bottom-edge note: which rule is asking. */
44
+ export const SAFETY_FOOTNOTE = {
45
+ RULE_7_SNAPSHOT_BEFORE_WRITE: 'Forge Rule 7',
46
+ RULE_7A_USE_UPDATE_METHOD: 'Forge Rule 7a',
47
+ RULE_8_BATCH_PLAN: 'Forge Rule 8',
48
+ RULE_9_TRANSPORT_RELEASE: 'Forge Rule 9',
49
+ RULE_9_TRANSPORT_ISOLATION: 'Forge Rule 9',
50
+ RULE_10_VERIFY_AFTER_WRITE: 'Forge Rule 10',
51
+ CREDENTIAL_HANDOVER: 'Credential handover',
52
+ };
53
+ /** Rule 7 covers both overwrite and delete; the delete says so. */
54
+ export function safetyHeading(a) {
55
+ if (a.rule === 'RULE_7_SNAPSHOT_BEFORE_WRITE' && /delete/.test(a.op))
56
+ return 'This deletes an object from SAP';
57
+ return SAFETY_HEADING[a.rule];
58
+ }
59
+ /** 'set_source' → 'sap_set_source' so piece 1's tool labels apply. */
60
+ function toolNameFor(op) {
61
+ if (hasToolLabel(op))
62
+ return op;
63
+ if (hasToolLabel(`sap_${op}`))
64
+ return `sap_${op}`;
65
+ return op;
66
+ }
67
+ /**
68
+ * The card keeps at most this many what_will_happen lines (the Rule 8 batch
69
+ * summary lists every write this turn — 15+ on a Fiori build). The spec §2
70
+ * height cap (rows − 2) holds only while fact rows stay bounded; the full
71
+ * text goes to scrollback first (presentSafetyConfirmation), like the plan gate.
72
+ */
73
+ export const SAFETY_WHAT_MAX_LINES = 4;
74
+ /** what_will_happen as display-safe, non-blank lines. */
75
+ function whatLines(a) {
76
+ return a.what_will_happen.split('\n').map(clean).filter((t) => t.length > 0);
77
+ }
78
+ /**
79
+ * At most SAFETY_WHAT_MAX_LINES rows. The Rule 8 summary puts the op being
80
+ * approved on its LAST line ("… ← about to dispatch"), so an over-long text
81
+ * keeps its header line, a "… N earlier, listed above" row, and the last lines.
82
+ */
83
+ function boundedWhatLines(lines) {
84
+ if (lines.length <= SAFETY_WHAT_MAX_LINES)
85
+ return lines;
86
+ const tail = SAFETY_WHAT_MAX_LINES - 2;
87
+ const hidden = lines.length - 1 - tail;
88
+ return [lines[0], `${getGlyphs().more} ${hidden} earlier, listed above`, ...lines.slice(lines.length - tail)];
89
+ }
90
+ /**
91
+ * Piece 1's present-tense tool label, fed the real tool args when the caller
92
+ * passed them. A label with no readable object (sap_update_method without
93
+ * class_name/method_name renders "Updating → ") falls back to the op words
94
+ * plus the object type and name.
95
+ */
96
+ function whatLabel(a) {
97
+ const tool = toolNameFor(a.op);
98
+ const fallback = clean(`${a.op.replace(/_/g, ' ')} ${a.object.type} ${a.object.name}`);
99
+ if (!hasToolLabel(tool))
100
+ return fallback;
101
+ const args = a.args ?? { name: a.object.name, type: a.object.type, transport: a.object.name, path: a.object.name };
102
+ if (!/[\p{L}\p{N}]/u.test(toolLabel(tool, args).object))
103
+ return fallback;
104
+ return toolActivityLabel(tool, args);
105
+ }
106
+ /** What / Where / Undo rows (plain text; the card paints them dim). */
107
+ export function safetyFacts(a) {
108
+ const what = a.rule === 'CREDENTIAL_HANDOVER'
109
+ ? `Hand the SAP login for ${clean(a.object.name)} to a local program`
110
+ : whatLabel(a);
111
+ const facts = [`What ${what}`];
112
+ for (const t of boundedWhatLines(whatLines(a)))
113
+ facts.push(` ${t}`);
114
+ const transport = clean(a.transport ?? '');
115
+ const where = transport.length > 0 ? `transport ${transport}` : 'no transport';
116
+ const batch = a.rule === 'RULE_8_BATCH_PLAN' && typeof a.batch_count === 'number'
117
+ ? ` · ${a.batch_count} objects this turn`
118
+ : '';
119
+ facts.push(`Where ${where}${batch}`);
120
+ facts.push(`Undo ${a.rollback_available ? 'A snapshot is taken first, so it can be restored' : 'Cannot be undone'}`);
121
+ return facts;
122
+ }
31
123
  export async function presentSafetyConfirmation(action) {
32
124
  // B5 (2026-06-11) — headless fail-fast. This card is a stdin modal (the
33
125
  // Rule 8 batch gate reaches it on the 2nd+ mutating call per turn even in
@@ -43,86 +135,70 @@ export async function presentSafetyConfirmation(action) {
43
135
  headless: true,
44
136
  };
45
137
  }
46
- const card = renderCard(action);
47
- // UX-5 review I1 (2026-06-12) — Ink-native branch. /safety-mode on made
48
- // this card reachable under Ink, where the raw inquirer select() below
49
- // toggled raw mode out from under the React frame and painted over it.
50
- // Mirror of renderPlanGate (approvals/render.ts): route through the
51
- // askQuestionEmitter choice modal — one input system, no stdin handoff.
52
- // The modal renders `context` itself in dimColor, so strip the chalk ANSI
53
- // codes (they'd show as raw escape text). Same choice semantics as the
54
- // classic path below: Apply/Cancel, plus the Rule 9 irreversibility ack.
55
- // Esc / no listener resolve as cancelled → decline (safe default).
138
+ // Piece 2 §5 — Ink: a SAFETY CHECK (or CANNOT BE UNDONE) confirm card in
139
+ // the card slot. The card slot claims input focus first (requestConfirm
140
+ // runs inside withFocusClaim: suspendActiveSpinner + turnStatusEmitter.pause,
141
+ // both resumed after) — the 2026-06-13 doubled-card / Enter-leak / wedge fix,
142
+ // now in one place.
143
+ // Same values as the classic path: anything but 'apply' declines; Rule 9
144
+ // release asks a second time with Cancel first. Reasons byte-identical.
56
145
  if (shouldUseInk()) {
57
- // CRITICAL race fix (2026-06-13) — this gate fires from INSIDE
58
- // dispatchTool, on the 2nd+ mutating op of a turn. For local-FS ops
59
- // (file_write / shell_exec, the fiori-build path) the agent loop
60
- // classifies the tool as NON-interactive: it starts the in-place tool
61
- // spinner, a per-second labelled heartbeat, AND keeps the turn-status
62
- // row ticking — none of which it pauses (only ask_question /
63
- // request_approval get paused at the loop level). So when this modal
64
- // opens, three render sources are still repainting the Ink frame
65
- // underneath it. That produced the observed live bug: the RULE 8 card
66
- // rendered DOUBLED, Enter was not consumed by the modal, the keystroke
67
- // LEAKED to the outer prompt (history-replay of the slash command), and
68
- // the turn wedged for ~10 minutes while the tool spinner kept ticking.
69
- //
70
- // The gate must claim exclusive input focus itself (it can't rely on the
71
- // loop, which doesn't know a non-interactive tool is about to block on
72
- // the user). Mirror what the loop does for interactive tools:
73
- // - clearActiveSpinner(): stop the live in-place tool spinner so only
74
- // ONE surface (this modal) renders.
75
- // - turnStatusEmitter.pause(): freeze the ticking status row so it
76
- // stops repainting the dynamic frame and overdrawing the modal.
77
- // resume() in the finally restores normal rendering the moment the gate
78
- // resolves. (The per-second heartbeat is loop-local and is stopped on
79
- // the loop side by ordering the gate ahead of heartbeat start — see
80
- // tool-dispatch.ts / loop.ts.)
81
- clearActiveSpinner();
82
- const statusWasActive = turnStatusEmitter.snapshot().active;
83
- if (statusWasActive)
84
- turnStatusEmitter.pause();
85
- try {
86
- const first = await askQuestionEmitter.request({
87
- id: 'safety-confirm',
88
- question: `⚠ ${RULE_LABEL[action.rule]}`,
89
- context: stripAnsi(card).trim(),
90
- kind: 'choice',
91
- choices: [
92
- { value: 'apply', label: 'Apply' },
93
- { value: 'cancel', label: 'Cancel' },
94
- ],
95
- });
96
- if (first.cancelled || first.answer !== 'apply') {
97
- return { confirmed: false, reason: 'user declined first prompt' };
98
- }
99
- // RULE 9 transport-release is irreversible — require a second explicit ack.
100
- if (action.rule === 'RULE_9_TRANSPORT_RELEASE') {
101
- const second = await askQuestionEmitter.request({
102
- id: 'safety-confirm-irreversible',
103
- question: `IRREVERSIBLE — releasing ${action.transport} cannot be undone from this side. ` +
104
- `Are you absolutely sure?`,
105
- kind: 'choice',
106
- // Cancel first → the modal cursor starts there (mirrors the classic
107
- // path's `default: 'cancel'`).
108
- choices: [
109
- { value: 'cancel', label: 'Cancel' },
110
- { value: 'release', label: 'Release transport' },
111
- ],
112
- });
113
- if (second.cancelled || second.answer !== 'release') {
114
- return { confirmed: false, reason: 'user declined irreversibility ack' };
115
- }
116
- }
117
- return { confirmed: true };
146
+ // Rule 9 asks twice. One focus claim spans both cards (refcounted with
147
+ // the claim each card takes), so the status row never resumes and ticks
148
+ // for a moment between them (C2 fix round 1). No listener → no claim.
149
+ if (action.rule === 'RULE_9_TRANSPORT_RELEASE' && cardSlotEmitter.listenerCount('confirm') > 0) {
150
+ return withFocusClaim(() => confirmOnCards(action));
118
151
  }
119
- finally {
120
- // Restore normal rendering. resume() is a no-op if the turn already
121
- // ended (e.g. the user Ctrl+C'd mid-modal) or was never paused.
122
- if (statusWasActive)
123
- turnStatusEmitter.resume();
152
+ return confirmOnCards(action);
153
+ }
154
+ const card = renderCard(action);
155
+ return confirmClassic(action, card);
156
+ }
157
+ /** The Ink cards (see presentSafetyConfirmation). */
158
+ async function confirmOnCards(action) {
159
+ const release = action.rule === 'RULE_9_TRANSPORT_RELEASE';
160
+ const what = whatLines(action);
161
+ if (what.length > SAFETY_WHAT_MAX_LINES) {
162
+ // The card keeps the header and the last lines; the whole text lands above it.
163
+ emitTrace(what.join('\n'));
164
+ }
165
+ const first = await askConfirm({
166
+ id: 'safety-confirm',
167
+ tag: release ? 'CANNOT BE UNDONE' : 'SAFETY CHECK',
168
+ title: safetyHeading(action),
169
+ facts: safetyFacts(action),
170
+ choices: [
171
+ { value: 'apply', label: 'Apply' },
172
+ { value: 'cancel', label: 'Cancel' },
173
+ ],
174
+ initial: 0,
175
+ footnote: SAFETY_FOOTNOTE[action.rule],
176
+ });
177
+ if (first.cancelled || first.value !== 'apply') {
178
+ return { confirmed: false, reason: 'user declined first prompt' };
179
+ }
180
+ if (release) {
181
+ const second = await askConfirm({
182
+ id: 'safety-confirm-irreversible',
183
+ tag: 'CANNOT BE UNDONE',
184
+ title: `Release ${clean(action.transport)}? This cannot be undone from here.`,
185
+ facts: ['Its objects move on to the next system, and the transport can no longer be changed.'],
186
+ // Cancel first → the cursor starts on the safe option (classic: default 'cancel').
187
+ choices: [
188
+ { value: 'cancel', label: 'Cancel' },
189
+ { value: 'release', label: 'Release transport' },
190
+ ],
191
+ initial: 0,
192
+ footnote: SAFETY_FOOTNOTE[action.rule],
193
+ });
194
+ if (second.cancelled || second.value !== 'release') {
195
+ return { confirmed: false, reason: 'user declined irreversibility ack' };
124
196
  }
125
197
  }
198
+ return { confirmed: true };
199
+ }
200
+ /** The classic inquirer path (unchanged). */
201
+ async function confirmClassic(action, card) {
126
202
  // 2026-05-01: switched from confirm() to select() to fix the "press Enter
127
203
  // first, then 'y'" race observed in dispatched-tool rule-8 prompts. confirm
128
204
  // has a brief window after rendering its multi-line message before raw
@@ -161,19 +237,11 @@ export async function presentSafetyConfirmation(action) {
161
237
  }
162
238
  return { confirmed: true };
163
239
  }
164
- /**
165
- * Strip chalk ANSI escape codes. The Ink ask-question modal renders the
166
- * card via its own <Text dimColor> — embedded escapes would display as
167
- * literal `[33m` noise there. (Same approach as renderPlanGate.)
168
- */
169
- function stripAnsi(s) {
170
- return s.replace(/\x1b\[[0-9;]*m/g, '');
171
- }
172
240
  /**
173
241
  * Minimal indented summary block — replaces the chunky ASCII-art box.
174
242
  * Renders as:
175
243
  *
176
- * ⚠ RULE 8 — no batch without plan
244
+ * ! RULE 8 — no batch without plan
177
245
  * op: sap_set_source
178
246
  * object: PROG ZTEST_FIXED_2
179
247
  * transport: (none)
@@ -181,7 +249,7 @@ function stripAnsi(s) {
181
249
  * what: Batch in progress (...)
182
250
  * rollback: available (snapshot)
183
251
  *
184
- * The `⚠` glyph is yellow (alert) — distinct from peach (brand) and red
252
+ * The `!` glyph is yellow (alert) — distinct from peach (brand) and red
185
253
  * (error) so the user immediately recognises a Forge Rule prompt. Body
186
254
  * lines are indented under the header for visual grouping without a box.
187
255
  */
@@ -190,7 +258,7 @@ function renderCard(action) {
190
258
  const transport = action.transport && action.transport.length > 0 ? action.transport : '(none)';
191
259
  const rollback = action.rollback_available ? 'available (snapshot)' : chalk.red('NOT available');
192
260
  lines.push('');
193
- lines.push(`${chalk.yellow('⚠')} ${chalk.yellow.bold(RULE_LABEL[action.rule])}`);
261
+ lines.push(`${chalk.yellow(getGlyphs().attention)} ${chalk.yellow.bold(RULE_LABEL[action.rule])}`);
194
262
  lines.push(` ${chalk.dim('op: ')} ${action.op}`);
195
263
  lines.push(` ${chalk.dim('object: ')} ${action.object.type} ${action.object.name}`);
196
264
  lines.push(` ${chalk.dim('transport:')} ${transport}`);
@@ -7,14 +7,18 @@
7
7
  */
8
8
  import { formatCost } from '../cost/pricing.js';
9
9
  import { creditsActive, formatCredits, CREDITS_PENDING } from '../cost/credits.js';
10
- export function formatSessionSpendLine(sessionCostUsd, credits) {
10
+ /** The spend VALUE in the current display mode (piece 2: goodbye block). */
11
+ export function formatSessionSpendValue(sessionCostUsd, credits) {
11
12
  if (!creditsActive())
12
- return ` spend: ${formatCost(sessionCostUsd)}`;
13
+ return formatCost(sessionCostUsd);
13
14
  if (!credits)
14
- return ` spend: ${CREDITS_PENDING}`;
15
+ return CREDITS_PENDING;
15
16
  const value = formatCredits(credits.sessionTotal, credits.unitLabel);
16
17
  const balance = credits.balance !== null
17
18
  ? ` · balance ${Math.round(credits.balance).toLocaleString('en-US')}`
18
19
  : '';
19
- return ` spend: ${value}${balance}`;
20
+ return `${value}${balance}`;
21
+ }
22
+ export function formatSessionSpendLine(sessionCostUsd, credits) {
23
+ return ` spend: ${formatSessionSpendValue(sessionCostUsd, credits)}`;
20
24
  }
@@ -0,0 +1,19 @@
1
+ // src/repl/themed-prompts.ts
2
+ //
3
+ // Piece 2 §10: the inquirer prompts with the CSPeach theme applied by default.
4
+ // Wizards import from here instead of '@inquirer/prompts'. The flow, choices,
5
+ // defaults and returned values are exactly the underlying prompt's; a
6
+ // caller-supplied `theme` still wins.
7
+ //
8
+ // The underlying prompt is looked up when the wrapper is CALLED (not at import
9
+ // time), so a test that mocks '@inquirer/prompts' with only some of the four
10
+ // prompts can still load a module that imports this one.
11
+ import * as raw from '@inquirer/prompts';
12
+ import { inquirerTheme } from './inquirer-theme.js';
13
+ function themed(pick) {
14
+ return ((config, context) => pick()({ theme: inquirerTheme, ...config }, context));
15
+ }
16
+ export const select = themed(() => raw.select);
17
+ export const input = themed(() => raw.input);
18
+ export const confirm = themed(() => raw.confirm);
19
+ export const password = themed(() => raw.password);
@@ -0,0 +1,75 @@
1
+ // src/repl/ui-look-command.ts — `/ui look [panels|legacy]` and `/ui [auto|ink]`,
2
+ // shared by the Ink and classic /ui handlers. A look change applies immediately
3
+ // (no relaunch needed) and persists; a rendering-mode change applies after relaunch.
4
+ import chalk from 'chalk';
5
+ import { setLook } from '../renderer/look.js';
6
+ import { formatFailure, formatInfo } from '../renderer/notice-shape.js';
7
+ import { panelHeading, panelRow } from '../renderer/panel-rows.js';
8
+ import { paint } from '../renderer/theme.js';
9
+ import { getGlyphs } from '../renderer/glyphs.js';
10
+ import { CLASSIC_ENV } from '../renderer/rendering-mode.js';
11
+ export async function handleUiLook(arg, cfg, save, emit) {
12
+ const m = /^look(?:\s+(\S+))?$/.exec(arg.trim().toLowerCase());
13
+ if (!m)
14
+ return false;
15
+ const value = m[1];
16
+ if (!value) {
17
+ emit(`Current look: ${chalk.bold(cfg.ui.look)}`);
18
+ emit(panelRow('/ui look panels', 'the new look (default)', 16));
19
+ emit(panelRow('/ui look legacy', 'the previous look', 16));
20
+ return true;
21
+ }
22
+ if (value !== 'panels' && value !== 'legacy') {
23
+ emit(formatFailure({ what: `Unknown look: ${value}. Try panels or legacy.` }).trimEnd());
24
+ return true;
25
+ }
26
+ cfg.ui.look = value;
27
+ setLook(value);
28
+ try {
29
+ await save(cfg);
30
+ emit(`${paint('ok', getGlyphs().done)} Look set to ${value}.`);
31
+ }
32
+ catch (err) {
33
+ emit(formatFailure({ what: `Failed to save config: ${err.message} (applied for this session only)` }).trimEnd());
34
+ }
35
+ return true;
36
+ }
37
+ /**
38
+ * `/ui [auto|ink]` — shared by the Ink and classic /ui handlers.
39
+ *
40
+ * Owner ruling 2026-09-27: the classic renderer is retiring. `/ui classic` no
41
+ * longer persists (a stored `classic` is not honoured anyway — see
42
+ * renderer/rendering-mode.ts); the renderer cannot switch mid-session, so it
43
+ * says how to get classic for one launch: the CSPEACH_UI=classic env var.
44
+ */
45
+ export async function handleUiRendering(arg, cfg, save, emit, effective) {
46
+ const g = getGlyphs();
47
+ if (arg === '') {
48
+ emit('');
49
+ emit(panelHeading('UI mode', `${cfg.ui.rendering} · effective now: ${effective}`));
50
+ emit(panelRow('/ui ink', 'the Ink frame', 9));
51
+ emit(panelRow('/ui auto', 'choose from the terminal', 9));
52
+ emit(panelRow('/ui look', 'panels (default) or legacy', 9));
53
+ emit(formatInfo('Mode changes apply after /exit and a relaunch; look changes apply now').trimEnd());
54
+ emit(formatInfo(`Classic display is retiring · for one launch only: ${CLASSIC_ENV}=classic cspeach`).trimEnd());
55
+ return;
56
+ }
57
+ if (arg === 'classic') {
58
+ emit(formatInfo('Classic display is retiring · /ui classic is no longer saved').trimEnd());
59
+ emit(formatInfo(`For one launch only, start cspeach with the environment variable ${CLASSIC_ENV}=classic`).trimEnd());
60
+ return;
61
+ }
62
+ if (arg === 'auto' || arg === 'ink') {
63
+ cfg.ui.rendering = arg;
64
+ try {
65
+ await save(cfg);
66
+ emit(`${paint('ok', g.done)} UI mode set to ${arg}`);
67
+ emit(formatInfo('Relaunch cspeach (/exit, then cspeach) to apply').trimEnd());
68
+ }
69
+ catch (err) {
70
+ emit(formatFailure({ what: 'Could not save the config', detail: [err.message] }).trimEnd());
71
+ }
72
+ return;
73
+ }
74
+ emit(formatFailure({ what: `Unknown UI mode: ${arg}`, next: '/ui auto or /ui ink' }).trimEnd());
75
+ }
@@ -5,6 +5,7 @@
5
5
  * - replPreviewHook — interactive REPL: renders diff + asks confirm
6
6
  * - noopPreviewHook — auto-confirm (reserved for trusted paths)
7
7
  * - alwaysDeclinePreviewHook — refuse every confirm (one-shot / non-interactive)
8
+ * - inkPreviewHook — Ink REPL: diff to scrollback + Apply/Cancel confirm card
8
9
  *
9
10
  * B.0a plumbing: factories are wired into ToolContext.previewHook at all 5
10
11
  * construction sites. B.1 will consume ctx.previewHook inside sap_update_method.
@@ -12,6 +13,9 @@
12
13
  import { confirm } from '@inquirer/prompts';
13
14
  import { renderDiff } from './diff-display.js';
14
15
  import { withInquirer } from './inquirer-guard.js';
16
+ import { askConfirm } from '../ui/confirm-request.js';
17
+ import { sanitizeControlChars } from '../renderer/sanitize.js';
18
+ import { withSpinnerSuspended } from '../renderer/tool-widget.js';
15
19
  /**
16
20
  * B.1 real interactive hook — composes renderDiff + @inquirer/prompts.confirm.
17
21
  * Renders a unified diff inside the REPL and asks the user "Apply change?".
@@ -21,13 +25,52 @@ export const replPreviewHook = async ({ className, methodName, currentBody, prop
21
25
  label: `${className}->${methodName}`,
22
26
  syntaxHighlight: true,
23
27
  });
24
- process.stdout.write(`\n${diff}\n`);
25
- const confirmed = await withInquirer(() => confirm({
26
- message: `Apply change to ${className}->${methodName}?`,
27
- default: false,
28
- }));
28
+ // Review M1: diff + prompt with the tool spinner suspended throughout, so
29
+ // no spinner frame repaints around the diff before the prompt opens.
30
+ const confirmed = await withSpinnerSuspended(async () => {
31
+ process.stdout.write(`\n${diff}\n`);
32
+ return withInquirer(() => confirm({
33
+ message: `Apply change to ${className}->${methodName}?`,
34
+ default: false,
35
+ }));
36
+ });
29
37
  return { confirmed };
30
38
  };
39
+ /**
40
+ * Piece 2 §6.3 — the Ink twin of replPreviewHook. inquirer must never run
41
+ * while Ink is mounted (it steals keystrokes), so the diff goes to
42
+ * scrollback through the chunk emitter and an Apply / Cancel confirm card
43
+ * decides. Cursor starts on Cancel and Esc / no listener = false — the
44
+ * same answer as today's confirm({ default: false }).
45
+ */
46
+ export function inkPreviewHook(chunkEmitter) {
47
+ return async ({ className, methodName, currentBody, proposedBody }) => {
48
+ // Model / SAP text goes to Ink's <Static> scrollback: neutralise ESC, BEL,
49
+ // lone CR etc. BEFORE diffing (newlines and tabs survive, so the diff is
50
+ // still line-accurate). renderDiff itself does not sanitize.
51
+ const diff = renderDiff(sanitizeControlChars(currentBody), sanitizeControlChars(proposedBody), {
52
+ label: sanitizeControlChars(`${className}->${methodName}`),
53
+ syntaxHighlight: true,
54
+ });
55
+ chunkEmitter.emit('chunk', `\n${diff}\n`);
56
+ const res = await askConfirm({
57
+ id: 'update-method-preview',
58
+ tag: 'APPROVAL',
59
+ title: `Apply this change to method ${methodName} in ${className}?`,
60
+ facts: [
61
+ 'The change is shown above.',
62
+ 'Only this method is replaced. The rest of the class is untouched.',
63
+ ],
64
+ choices: [
65
+ { value: 'apply', label: 'Apply' },
66
+ { value: 'cancel', label: 'Cancel' },
67
+ ],
68
+ initial: 1,
69
+ footnote: 'Forge Rule 7a',
70
+ });
71
+ return { confirmed: res.value === 'apply' };
72
+ };
73
+ }
31
74
  /**
32
75
  * No-op hook — confirms automatically without prompting. Use for paths where we
33
76
  * trust the LLM (classifier; doctor probe-write where the developer initiated