@cspeach/cli 1.1.17 → 1.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/README.md +3 -4
  2. package/dist/agent/loop.js +143 -25
  3. package/dist/agent/providers/local-provider.js +4 -1
  4. package/dist/agent/repair-partial.js +76 -19
  5. package/dist/agent/tool-dispatch.js +80 -22
  6. package/dist/agent/turn-error-ux.js +77 -26
  7. package/dist/agent/turn-stream.js +25 -3
  8. package/dist/approvals/adt-type.js +27 -0
  9. package/dist/approvals/advisory-prompt.js +48 -0
  10. package/dist/approvals/advisory-render.js +25 -11
  11. package/dist/approvals/approval-prompt.js +74 -24
  12. package/dist/approvals/jwt.js +2 -1
  13. package/dist/approvals/render.js +64 -36
  14. package/dist/approvals/risk-floor.js +53 -2
  15. package/dist/auth/api-key.js +30 -16
  16. package/dist/auth/keychain.js +0 -0
  17. package/dist/auth/me.js +25 -7
  18. package/dist/cli.js +25 -6
  19. package/dist/commands/compact.js +12 -9
  20. package/dist/commands/config-set.js +6 -2
  21. package/dist/commands/config-show.js +7 -5
  22. package/dist/commands/cost.js +41 -34
  23. package/dist/commands/help.js +88 -50
  24. package/dist/commands/login.js +28 -19
  25. package/dist/commands/logout.js +7 -3
  26. package/dist/commands/plan-chain.js +71 -77
  27. package/dist/commands/plan-continue.js +1 -0
  28. package/dist/commands/plan-resume.js +20 -12
  29. package/dist/commands/whoami.js +23 -9
  30. package/dist/config/loader.js +15 -3
  31. package/dist/cost/pricing.js +6 -5
  32. package/dist/doctor/checks/forge-rules.js +13 -3
  33. package/dist/doctor/checks/keychain.js +6 -4
  34. package/dist/doctor/run.js +101 -20
  35. package/dist/index.js +5 -2
  36. package/dist/lib/graceful-exit.js +56 -0
  37. package/dist/lib/piped-prompt.js +65 -0
  38. package/dist/lock-contention.js +2 -2
  39. package/dist/one-shot.js +135 -40
  40. package/dist/projects/answer-blockers.js +6 -1
  41. package/dist/projects/handover-md.js +15 -14
  42. package/dist/projects/plan-run.js +97 -54
  43. package/dist/projects/promote-command.js +11 -2
  44. package/dist/projects/save-command.js +3 -2
  45. package/dist/projects/status.js +2 -1
  46. package/dist/projects/yes-no.js +12 -0
  47. package/dist/renderer/abap-inline.js +18 -14
  48. package/dist/renderer/answer-trim.js +69 -0
  49. package/dist/renderer/banners.js +8 -8
  50. package/dist/renderer/brand-settled.js +19 -0
  51. package/dist/renderer/change-card.js +156 -0
  52. package/dist/renderer/checklist-format.js +130 -0
  53. package/dist/renderer/color-mode.js +45 -0
  54. package/dist/renderer/error-detail.js +104 -0
  55. package/dist/renderer/fence-state.js +46 -0
  56. package/dist/renderer/footer-line.js +96 -0
  57. package/dist/renderer/glyphs.js +25 -0
  58. package/dist/renderer/goodbye.js +38 -0
  59. package/dist/renderer/legacy-palette.js +41 -0
  60. package/dist/renderer/look.js +30 -0
  61. package/dist/renderer/markdown.js +299 -92
  62. package/dist/renderer/notice-log.js +72 -0
  63. package/dist/renderer/notice-shape.js +48 -0
  64. package/dist/renderer/notices.js +40 -8
  65. package/dist/renderer/panel-rows.js +16 -0
  66. package/dist/renderer/pipeline.js +66 -0
  67. package/dist/renderer/progress-chatter.js +23 -17
  68. package/dist/renderer/rendering-mode.js +34 -0
  69. package/dist/renderer/routing-line.js +14 -0
  70. package/dist/renderer/sanitize.js +12 -0
  71. package/dist/renderer/severity.js +2 -7
  72. package/dist/renderer/startup-lines.js +152 -0
  73. package/dist/renderer/status-footer.js +52 -34
  74. package/dist/renderer/steering-echo.js +41 -0
  75. package/dist/renderer/syntax.js +31 -12
  76. package/dist/renderer/tables.js +5 -1
  77. package/dist/renderer/theme.js +44 -0
  78. package/dist/renderer/thinking-heartbeat.js +33 -2
  79. package/dist/renderer/todo-block.js +10 -49
  80. package/dist/renderer/tool-labels.js +238 -0
  81. package/dist/renderer/tool-widget.js +245 -51
  82. package/dist/renderer/trace.js +42 -0
  83. package/dist/renderer/transcript-flow.js +132 -0
  84. package/dist/renderer/tty.js +34 -1
  85. package/dist/renderer/ui-width.js +42 -0
  86. package/dist/renderer/verify-chain.js +4 -2
  87. package/dist/renderer/widget-fallback.js +58 -65
  88. package/dist/repl/bracketed-paste.js +7 -1
  89. package/dist/repl/builtin-commands.js +19 -8
  90. package/dist/repl/credential-handover-gate.js +28 -0
  91. package/dist/repl/early-line-buffer.js +5 -2
  92. package/dist/repl/file-picker.js +54 -12
  93. package/dist/repl/ink-stdin-guard.js +66 -0
  94. package/dist/repl/inquirer-guard.js +59 -16
  95. package/dist/repl/inquirer-theme.js +27 -33
  96. package/dist/repl/paste-marker.js +19 -0
  97. package/dist/repl/plan-turn-end.js +20 -0
  98. package/dist/repl/post-turn-status.js +36 -11
  99. package/dist/repl/reroute-turn.js +21 -0
  100. package/dist/repl/reset-tty-stdin.js +44 -0
  101. package/dist/repl/restore-guard.js +22 -0
  102. package/dist/repl/resume-standalone.js +63 -0
  103. package/dist/repl/rule8-detector.js +11 -3
  104. package/dist/repl/safety-confirm.js +161 -93
  105. package/dist/repl/session-spend-line.js +8 -4
  106. package/dist/repl/themed-prompts.js +19 -0
  107. package/dist/repl/ui-look-command.js +75 -0
  108. package/dist/repl/update-method-preview-hook.js +48 -5
  109. package/dist/repl.js +795 -332
  110. package/dist/rewind/cli.js +3 -2
  111. package/dist/rewind/format.js +14 -9
  112. package/dist/rewind/restore.js +11 -0
  113. package/dist/router/classifier.js +47 -4
  114. package/dist/router/intent-extractor.js +24 -8
  115. package/dist/router/piped-routing.js +23 -0
  116. package/dist/router/resume-routing.js +16 -0
  117. package/dist/router/routing-failure.js +57 -0
  118. package/dist/sap/first-run-choice.js +1 -1
  119. package/dist/sap/onboarding.js +3 -2
  120. package/dist/sap/standalone-onboarding.js +1 -1
  121. package/dist/sap/system-info.js +4 -2
  122. package/dist/sap/unreachable.js +28 -0
  123. package/dist/sap-errors/clean-error-text.js +182 -0
  124. package/dist/session/interrupt-reason.js +39 -0
  125. package/dist/session/recap.js +36 -24
  126. package/dist/session/resume.js +104 -36
  127. package/dist/session/store.js +69 -27
  128. package/dist/session/user-prompt.js +21 -0
  129. package/dist/skill-catalog.js +7 -0
  130. package/dist/skills/bundled-skills.js +76 -69
  131. package/dist/skills/signing-public-key.js +1 -1
  132. package/dist/standards/standards-init.js +1 -1
  133. package/dist/test-helpers/answer-any.js +46 -0
  134. package/dist/tools/approval.js +27 -11
  135. package/dist/tools/ask-question.js +47 -5
  136. package/dist/tools/filesystem/file-write.js +19 -2
  137. package/dist/tools/fiori/fe-extend.js +16 -1
  138. package/dist/tools/fiori/fe-scaffold.js +105 -3
  139. package/dist/tools/fiori/html-escapes.js +28 -0
  140. package/dist/tools/fiori/metadata/audit.js +605 -0
  141. package/dist/tools/fiori/metadata/references.js +104 -0
  142. package/dist/tools/fiori/metadata/types.js +8 -0
  143. package/dist/tools/fiori/preview/app-guard.js +215 -0
  144. package/dist/tools/fiori/preview/env.js +193 -0
  145. package/dist/tools/fiori/preview/readiness.js +127 -0
  146. package/dist/tools/fiori/preview/registry.js +412 -0
  147. package/dist/tools/fiori/preview/start.js +469 -0
  148. package/dist/tools/fiori/samples/loader.js +20 -5
  149. package/dist/tools/fiori/scaffold.js +18 -6
  150. package/dist/tools/fiori/smoke/app-driver-page.js +372 -0
  151. package/dist/tools/fiori/smoke/app-driver.js +100 -0
  152. package/dist/tools/fiori/smoke/assertions.js +161 -24
  153. package/dist/tools/fiori/smoke/audit-columns.js +21 -0
  154. package/dist/tools/fiori/smoke/batch.js +273 -0
  155. package/dist/tools/fiori/smoke/draft-safety.js +241 -0
  156. package/dist/tools/fiori/smoke/draft-smoke.js +473 -0
  157. package/dist/tools/fiori/smoke/driver.js +171 -4
  158. package/dist/tools/fiori/smoke/evidence.js +35 -0
  159. package/dist/tools/fiori/smoke/labels-codes.js +207 -0
  160. package/dist/tools/fiori/smoke/nav-error.js +26 -0
  161. package/dist/tools/fiori/smoke/run-smoke.js +155 -12
  162. package/dist/tools/fiori/tools.js +651 -7
  163. package/dist/tools/fiori/ui5-version.js +16 -0
  164. package/dist/tools/index.js +8 -0
  165. package/dist/tools/local-build.js +6 -0
  166. package/dist/tools/sap-read.js +16 -1
  167. package/dist/tools/sap-write.js +122 -10
  168. package/dist/tools/shell/shell_exec.js +30 -12
  169. package/dist/tools/syntax-state.js +67 -0
  170. package/dist/tools/todo.js +23 -18
  171. package/dist/tools/transport-fit.js +462 -0
  172. package/dist/tools/transport-resolution.js +3 -1
  173. package/dist/tools/transport.js +33 -7
  174. package/dist/tools/verify.js +184 -8
  175. package/dist/ui/app.js +284 -146
  176. package/dist/ui/approval-modal.js +41 -15
  177. package/dist/ui/ask-answer-text.js +17 -0
  178. package/dist/ui/body.js +25 -6
  179. package/dist/ui/card-slot.js +98 -0
  180. package/dist/ui/checklist.js +9 -0
  181. package/dist/ui/coaching-picker-classic.js +8 -5
  182. package/dist/ui/command-palette.js +6 -4
  183. package/dist/ui/confirm-request.js +18 -0
  184. package/dist/ui/context-grid.js +2 -1
  185. package/dist/ui/file-palette.js +6 -4
  186. package/dist/ui/files-card-emitter.js +19 -0
  187. package/dist/ui/footer-line.js +16 -0
  188. package/dist/ui/footer.js +105 -104
  189. package/dist/ui/input-wrap.js +122 -0
  190. package/dist/ui/interrupt.js +103 -0
  191. package/dist/ui/key-burst.js +117 -0
  192. package/dist/ui/line-resolution.js +4 -3
  193. package/dist/ui/live-area.js +71 -0
  194. package/dist/ui/login-banner.js +54 -43
  195. package/dist/ui/rewind-panel.js +76 -24
  196. package/dist/ui/role-props.js +47 -0
  197. package/dist/ui/sap-state-store.js +1 -0
  198. package/dist/ui/session-timeline.js +49 -3
  199. package/dist/ui/skill-picker.js +5 -47
  200. package/dist/ui/text-input.js +128 -24
  201. package/dist/ui/todo-emitter.js +19 -0
  202. package/dist/ui/turn-status-emitter.js +28 -2
  203. package/dist/ui/turn-status.js +16 -19
  204. package/dist/ui/use-card-request.js +44 -0
  205. package/dist/ui/widgets/ask-card.js +521 -0
  206. package/dist/ui/widgets/bar-chart.js +11 -5
  207. package/dist/ui/widgets/coaching-picker.js +50 -55
  208. package/dist/ui/widgets/confirm-card.js +193 -0
  209. package/dist/ui/widgets/dep-graph.js +3 -2
  210. package/dist/ui/widgets/diff-viewer.js +6 -5
  211. package/dist/ui/widgets/files-card.js +97 -0
  212. package/dist/ui/widgets/question-card.js +2 -1
  213. package/dist/ui/widgets/reclaim-raw-stdin.js +24 -0
  214. package/dist/ui/widgets/shortcuts-sheet.js +25 -0
  215. package/dist/ui/widgets/stack-frames.js +2 -1
  216. package/dist/upgrade-check.js +39 -8
  217. package/package.json +11 -2
  218. package/test-harness/fe-draft/app.ts +188 -0
  219. package/test-harness/fe-draft/fe-draft.e2e.test.ts +461 -0
  220. package/test-harness/fe-draft/manifest-enhancer.unit.test.ts +51 -0
  221. package/test-harness/fe-draft/routes.ts +224 -0
  222. package/test-harness/fe-draft/routes.unit.test.ts +45 -0
  223. package/test-harness/fe-draft/server.ts +225 -0
  224. package/test-harness/fe-draft/variants.ts +196 -0
  225. package/vitest.smoke-e2e.config.ts +20 -0
package/dist/repl.js CHANGED
@@ -6,37 +6,58 @@ import chalk from 'chalk';
6
6
  // callback-module's InterfaceConstructor signature or import from
7
7
  // 'node:readline' directly for that specific operation.
8
8
  import * as readline from 'node:readline/promises';
9
- import { select, password as promptPassword } from '@inquirer/prompts';
10
- import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
9
+ import { select, password as promptPassword } from './repl/themed-prompts.js';
10
+ import { loadConfig, resolveLocalBuild, resolveStandalone, DEFAULT_CONFIG } from './config/loader.js';
11
+ import { getLook, setLook, setBackground } from './renderer/look.js';
12
+ import { applyColorMode } from './renderer/color-mode.js';
13
+ import { startEarlyBrand, aliasSourceNote, panelsHintLine, panelsResumeLine, panelsStartTail, panelsInterruptedNote, formatPromptEcho } from './renderer/startup-lines.js';
14
+ import { formatAttention, formatFailure, formatInfo } from './renderer/notice-shape.js';
15
+ import { formatPanel } from './renderer/panel-rows.js';
16
+ import { sanitizeControlChars } from './renderer/sanitize.js';
17
+ import { formatRoutingLine } from './renderer/routing-line.js';
18
+ import { handleUiLook, handleUiRendering } from './repl/ui-look-command.js';
19
+ import { CLI_VERSION } from './lib/version.js';
11
20
  import { createOfflineAdtClient } from './sap/offline-adt-client.js';
12
21
  import { STANDALONE_ALIAS } from './tools/index.js';
22
+ import { resumeStandaloneDecision, applySystemSwitch } from './repl/resume-standalone.js';
23
+ import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
24
+ import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
13
25
  import { getStore } from './auth/api-key.js';
14
26
  import { readAuthFile } from './auth/auth-file.js';
15
27
  import { fetchMe } from './auth/me.js';
16
- import { formatLoginBanner } from './ui/login-banner.js';
17
- import { printStartupBanner, printKeytarFallbackBanner } from './renderer/banners.js';
28
+ import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
29
+ import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
30
+ import { paint } from './renderer/theme.js';
31
+ import { steeringEchoLine, classifyMidTurnLine, midTurnSlashHint } from './renderer/steering-echo.js';
18
32
  import { runAddSapWizard } from './sap/onboarding.js';
19
33
  import { getConnection } from './sap/connection-manager.js';
34
+ import { unreachableCode, unreachableMessage } from './sap/unreachable.js';
20
35
  import { AdtClient, snapshots } from '@cspeach/sap-client';
21
- import { makeSessionId, saveSession } from './session/store.js';
36
+ import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
22
37
  import { newSession } from './session/schema.js';
23
38
  import { resolveModelRole } from './models/resolve.js';
24
39
  import { loadServerModelConfig } from './models/server-config.js';
25
40
  import { computeCost } from './cost/pricing.js';
26
- import { formatSessionSpendLine } from './repl/session-spend-line.js';
41
+ import { formatSessionSpendValue } from './repl/session-spend-line.js';
42
+ import { formatGoodbye } from './renderer/goodbye.js';
27
43
  import { globalCredits } from './cost/credits.js';
28
44
  import { initCredits } from './cost/credits-wire.js';
29
45
  import { createProviderForMode } from './agent/providers/factory.js';
30
46
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
31
47
  import { runTurn } from './agent/loop.js';
32
48
  import { renderTurnError } from './agent/turn-error-ux.js';
49
+ import { runRerouteTurn } from './repl/reroute-turn.js';
33
50
  // BUG-2b (2026-07-05 live smoke) — pure (no-Ink) modules, safe to import
34
51
  // statically even though App itself is lazy-loaded to keep Ink out of
35
52
  // classic mode. Used by the single-flight dispatch guard below.
36
53
  import { turnStatusEmitter } from './ui/turn-status-emitter.js';
54
+ import { decideInterrupt, createPendingTurnCancel, pendingCancelLine, queuedWorkLabel, autoDispatchDecision, settleWithin, EXIT_TAIL_CAP_MS } from './ui/interrupt.js';
55
+ import { setTraceEmitter } from './renderer/trace.js';
37
56
  import { enqueueSteering } from './agent/steering-queue.js';
38
57
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
58
+ import { pipedRoutingPick } from './router/piped-routing.js';
39
59
  import { decideRouting } from './router/routing-decision.js';
60
+ import { routingFailureFromError, routingFailureFromResult, routingFailureNotice, routingFailureOutcome, routingFailureText } from './router/routing-failure.js';
40
61
  import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
41
62
  import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
42
63
  import { globalStore as sapStateStore } from './ui/sap-state-store.js';
@@ -51,6 +72,9 @@ import { approvalEmitter } from './ui/approval-emitter.js';
51
72
  import { collectRewindCandidates } from './rewind/candidates.js';
52
73
  import { executeRestore, runClassicRewind } from './rewind/cli.js';
53
74
  import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
75
+ import { pipeRevivedStdin } from './repl/reset-tty-stdin.js';
76
+ import { logsDir } from './config/paths.js';
77
+ import { noticeLogPathFor, pruneNoticeLogs, setNoticeLogPath } from './renderer/notice-log.js';
54
78
  import { maybePromptWorkspaceMigration, maybeOfferAbapforgeMigration } from './projects/migration.js';
55
79
  import { findProjectRoot } from './projects/workspace.js';
56
80
  import { slashCompleter } from './repl/slash-completer.js';
@@ -81,19 +105,26 @@ function prefillReadline(rl, text) {
81
105
  import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
82
106
  import { InkStdinPasteGuard } from './repl/ink-stdin-guard.js';
83
107
  import { printHelp } from './commands/help.js';
84
- import { shouldUseInk, setRenderingOverride } from './renderer/tty.js';
108
+ import { shouldUseInk, setOneShotOverride, getEffectiveBodyWidth } from './renderer/tty.js';
109
+ import { applyStartupRendering } from './renderer/rendering-mode.js';
85
110
  import { createClassicOutputEmitter } from './renderer/notices.js';
86
111
  import { saveConfig } from './config/loader.js';
87
112
  import { EventEmitter } from 'node:events';
113
+ import { existsSync } from 'node:fs';
114
+ import { join } from 'node:path';
115
+ import { streamsRoot } from './agent/turn-stream.js';
88
116
  import { parseAndValidateReroute } from './commands/reroute.js';
117
+ import { emitPlanTurnEnd } from './repl/plan-turn-end.js';
89
118
  import { SKILL_CATALOG } from './skill-catalog.js';
90
- import { replPreviewHook } from './repl/update-method-preview-hook.js';
119
+ import { replPreviewHook, inkPreviewHook } from './repl/update-method-preview-hook.js';
91
120
  import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
92
121
  import { matchSafetyModeCommand } from './repl/safety-mode-state.js';
93
122
  import { getEffectiveWriteMode, runModeCommand } from './repl/mode-cycle.js';
94
123
  import { maybeExpandNumberSelection, lastAssistantText } from './repl/numbered-menu.js';
95
124
  import { attachEarlyLineBuffer, noopEarlyLineBuffer } from './repl/early-line-buffer.js';
96
- import { snapshotTurnStart, pushPostTurnStatus } from './repl/post-turn-status.js';
125
+ import { createRestoreGuard } from './repl/restore-guard.js';
126
+ import { gracefulExit } from './lib/graceful-exit.js';
127
+ import { snapshotTurnStart, pushPostTurnStatus, seedFooterSpend } from './repl/post-turn-status.js';
97
128
  import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
98
129
  import { applyLocalBuildConfig, applyStandaloneFileTools } from './tools/local-build.js';
99
130
  // Side-effect imports so tool registrations happen before listTools() is called.
@@ -127,7 +158,6 @@ import './tools/subagent/background_run.js';
127
158
  import './tools/subagent/monitor_emit.js';
128
159
  import './tools/subagent/schedule_draft_create.js';
129
160
  import './tools/subagent/agent_run.js';
130
- const PEACH = '#FFCBA4';
131
161
  const HISTORY_SIZE = 500;
132
162
  // Persistence (read + dedup-guarded append) lives in ./repl/history.ts —
133
163
  // the single writer shared with the Ink Footer (Task 8, ux-wave1).
@@ -228,6 +258,15 @@ function isAckContinuation(trimmed, sessionSkill) {
228
258
  * shell shows only the startup banner + a bare "session saved" line,
229
259
  * which feels like everything they just did vanished.
230
260
  */
261
+ /** `~/.cspeach/streams/<id>/` when that folder exists, else null (fix round 3, item 11b). */
262
+ function existingStreamDir(sessionId) {
263
+ try {
264
+ return existsSync(join(streamsRoot(), sessionId)) ? `~/.cspeach/streams/${sessionId}/` : null;
265
+ }
266
+ catch {
267
+ return null;
268
+ }
269
+ }
231
270
  function printGoodbye(session) {
232
271
  const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
233
272
  const sessionCost = computeCost(session.model, {
@@ -240,16 +279,18 @@ function printGoodbye(session) {
240
279
  (session.usage.output_tokens ?? 0) +
241
280
  (session.usage.cache_read_input_tokens ?? 0) +
242
281
  (session.usage.cache_creation_input_tokens ?? 0);
243
- console.log('');
244
- console.log(chalk.bold(`Session ${session.id}`));
245
- console.log(chalk.dim(` turns: ${userTurns}`));
246
- console.log(chalk.dim(` last skill: ${session.skill ?? '(none)'}`));
247
- console.log(chalk.dim(` tokens: ${totalTokens.toLocaleString()} (input+output+cache)`));
248
- console.log(chalk.dim(formatSessionSpendLine(sessionCost, globalCredits.snapshot())));
249
- console.log('');
250
- console.log(chalk.cyan(`Resume with: cspeach --resume ${session.id}`));
251
- console.log(chalk.dim(`Full log: ~/.cspeach/streams/${session.id}.md`));
252
- console.log('');
282
+ for (const line of formatGoodbye({
283
+ sessionId: session.id,
284
+ turns: userTurns,
285
+ lastSkill: session.skill ?? null,
286
+ tokens: totalTokens,
287
+ spend: formatSessionSpendValue(sessionCost, globalCredits.snapshot()),
288
+ logDir: existingStreamDir(session.id),
289
+ // Round 6 review I1 — gate on the file: a start-then-quit (or a double
290
+ // Ctrl+C in the first turn before it saved) has no session to resume.
291
+ saved: sessionSaved(session),
292
+ }))
293
+ console.log(line);
253
294
  }
254
295
  function isBareAck(trimmed) {
255
296
  const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
@@ -308,7 +349,29 @@ function buildCoachingParams(result, prompt, preSelect, lastSkill) {
308
349
  };
309
350
  }
310
351
  export async function runRepl(opts) {
311
- printStartupBanner();
352
+ applyColorMode(); // NO_COLOR (piece 2 §1.2) — idempotent; main() already did it for the CLI path
353
+ // 2026-09-26 — the look must be known before the first startup line prints.
354
+ // A config read failure keeps the default look; the real loadConfig below
355
+ // still runs and reports errors exactly as before.
356
+ try {
357
+ const early = await loadConfig();
358
+ setLook(early.ui.look);
359
+ setBackground(early.ui.background);
360
+ }
361
+ catch {
362
+ setLook(DEFAULT_CONFIG.ui.look);
363
+ }
364
+ // Piece 2 §10: the account folds into the panels brand line; "!" notices
365
+ // collect here and print last. Owner decision 2026-09-27: the panels brand
366
+ // line prints FIRST (no blank terminal during the sign-in check and the
367
+ // rest of startup); the sign-in completes the same line below. Legacy keeps
368
+ // its pinned banner exactly as before.
369
+ const startupNotices = [];
370
+ if (getLook() !== 'panels')
371
+ printStartupBanner();
372
+ const earlyBrand = getLook() === 'panels'
373
+ ? startEarlyBrand({ version: CLI_VERSION, tty: process.stdout.isTTY === true, write: (s) => { process.stdout.write(s); } })
374
+ : undefined;
312
375
  // v0.6 — surface interrupted sessions at startup so the developer doesn't
313
376
  // have to remember the session id from the crash UX. Like `git stash list`
314
377
  // for resilience: if you have any unfinished work, you see it immediately.
@@ -318,8 +381,10 @@ export async function runRepl(opts) {
318
381
  const { countInterruptedSessions } = await import('./session/store.js');
319
382
  const n = await countInterruptedSessions();
320
383
  if (n > 0) {
321
- console.log(chalk.yellow.bold(`⚠ You have ${n} interrupted session${n === 1 ? '' : 's'}.`));
322
- console.log(chalk.yellow(` Recover with: cspeach --resume (picks the right one from a list)\n`));
384
+ if (getLook() === 'panels')
385
+ startupNotices.push(panelsInterruptedNote(n) + '\n');
386
+ else
387
+ console.log(formatAttention({ text: `You have ${n} interrupted session${n === 1 ? '' : 's'}`, next: 'cspeach --resume (picks the right one from a list)' }));
323
388
  }
324
389
  }
325
390
  catch { /* startup banner is best-effort */ }
@@ -331,7 +396,7 @@ export async function runRepl(opts) {
331
396
  // flag is process-scoped, so this prints at most once per CLI launch.
332
397
  try {
333
398
  const { isFlagOn } = await import('./agent/maybe-build-project-context.js');
334
- if (isFlagOn()) {
399
+ if (isFlagOn() && getLook() !== 'panels') {
335
400
  console.log(chalk.cyan(`[project-context] enabled (cwd=${process.cwd()})`));
336
401
  console.log(chalk.dim(' unset CSPEACH_PROJECT_CONTEXT to disable; run `cspeach analyze project-context` later to compare.\n'));
337
402
  }
@@ -341,6 +406,7 @@ export async function runRepl(opts) {
341
406
  // and render the verbose login banner. Falls through to the legacy keytar
342
407
  // flow below when the file is absent (back-compat for pre-`cspeach login`
343
408
  // users).
409
+ let signIn;
344
410
  try {
345
411
  const authFile = await readAuthFile();
346
412
  if (authFile) {
@@ -348,25 +414,44 @@ export async function runRepl(opts) {
348
414
  const meResult = await fetchMe({ proxyUrl: cfgEarly.proxy_url, apiKey: authFile.apiKey });
349
415
  if (!meResult.ok) {
350
416
  if (meResult.status === 401) {
351
- console.log(chalk.yellow('Your CSPeach session is invalid or expired. Run `cspeach login` to re-authenticate.'));
417
+ earlyBrand?.close();
418
+ console.log(formatFailure({ what: 'Your CSPeach sign-in is no longer valid', next: 'cspeach login' }));
352
419
  return 1;
353
420
  }
354
- console.log(chalk.dim(`(could not refresh /v1/me — ${meResult.error})`));
421
+ const note = formatInfo(`Could not refresh your account (${meResult.error})`);
422
+ if (getLook() === 'panels')
423
+ startupNotices.push(note);
424
+ else
425
+ console.log(note.trimEnd());
355
426
  }
356
427
  else {
357
428
  const banner = formatLoginBanner(meResult.profile);
358
- console.log(banner.line);
359
- if (banner.warningLine)
360
- console.log(chalk.yellow(banner.warningLine));
429
+ if (getLook() === 'panels') {
430
+ signIn = signInFragment(meResult.profile);
431
+ if (banner.warningLine)
432
+ startupNotices.push(formatAttention({ text: banner.warningLine }));
433
+ }
434
+ else {
435
+ console.log(banner.line);
436
+ if (banner.warningLine)
437
+ console.log(formatAttention({ text: banner.warningLine }).trimEnd());
438
+ }
361
439
  }
362
440
  }
363
441
  }
364
442
  catch { /* banner is best-effort — never block REPL startup */ }
443
+ earlyBrand?.finish(signIn);
365
444
  const store = await getStore();
366
- if (!(await store.isKeychainAvailable())) {
367
- printKeytarFallbackBanner();
368
- }
445
+ const keychainMissing = !(await store.isKeychainAvailable());
369
446
  let apiKey = await store.get();
447
+ if (keychainMissing) {
448
+ // Panels: a "!" line with the other notices — unless the API key prompt is
449
+ // about to open, which needs the explanation right now.
450
+ if (getLook() === 'panels' && apiKey)
451
+ startupNotices.push(keychainFallbackNotice());
452
+ else
453
+ printKeytarFallbackBanner();
454
+ }
370
455
  if (!apiKey) {
371
456
  const entered = await promptPassword({
372
457
  message: 'Paste your CSPeach API key (from invite email):',
@@ -376,19 +461,40 @@ export async function runRepl(opts) {
376
461
  apiKey = entered;
377
462
  }
378
463
  let cfg = await loadConfig();
379
- // Apply user-configured rendering override BEFORE any shouldUseInk() call.
380
- setRenderingOverride(cfg.ui.rendering);
464
+ setLook(cfg.ui.look);
465
+ // Apply the rendering mode BEFORE any shouldUseInk() call. This is the
466
+ // interactive entry point, so it is never one-shot whatever argv holds. A
467
+ // stored `classic` is retiring: read as auto, rewritten once, one dim notice
468
+ // (CSPEACH_UI=classic is the escape hatch) — see renderer/rendering-mode.ts.
469
+ setOneShotOverride(false);
470
+ const classicRetiringNotice = await applyStartupRendering(cfg, saveConfig);
471
+ if (classicRetiringNotice) {
472
+ if (getLook() === 'panels')
473
+ startupNotices.push(classicRetiringNotice);
474
+ else
475
+ process.stdout.write(classicRetiringNotice);
476
+ }
477
+ setBackground(cfg.ui.background);
381
478
  // Standalone (2026-09-16) — run with no SAP system at all. Decided BEFORE
382
479
  // the onboarding wizard: someone who asked for --no-sap (or persisted
383
480
  // no_sap) must never be pushed through the SAP setup they don't have.
384
481
  // Having aliases configured does NOT override the request.
385
482
  // A session that WAS standalone stays standalone on `--resume`: its
386
483
  // sap_system is the reserved 'none' alias, which has no entry in cfg.sap.
387
- let standalone = resolveStandalone(cfg, opts?.noSap === true) ||
388
- opts?.resumedSession?.sap_system === STANDALONE_ALIAS;
484
+ // Round 6 item 2 — …unless an explicit --sap-alias asks for a SAP system:
485
+ // then the flag wins and the resumed session moves to it.
486
+ const standaloneDecision = resumeStandaloneDecision({
487
+ requestedStandalone: resolveStandalone(cfg, opts?.noSap === true),
488
+ resumedSapSystem: opts?.resumedSession?.sap_system,
489
+ sapAlias: opts?.sapAlias,
490
+ standaloneSource: opts?.noSap === true ? '--no-sap' : 'no_sap setting',
491
+ });
492
+ let standalone = standaloneDecision.standalone;
389
493
  const enterStandalone = async () => {
390
- console.log(chalk.dim('\nStandalone mode: no SAP system connected — skills work from pasted code. ' +
391
- 'Connect later with: cspeach config add-sap'));
494
+ if (getLook() !== 'panels') {
495
+ console.log(chalk.dim('\nStandalone mode: no SAP system connected — skills work from pasted code. ' +
496
+ 'Connect later with: cspeach config add-sap'));
497
+ }
392
498
  // Ask the profile questions ONCE — they become the <sap_system> element
393
499
  // every skill reads, so without them a design would target "unknown".
394
500
  if (!cfg.standalone) {
@@ -444,12 +550,20 @@ export async function runRepl(opts) {
444
550
  // identity, so print here exactly as before (byte-identical output); when
445
551
  // any alias declares a role, defer the banner until the alias is resolved.
446
552
  const anyRoleConfigured = Object.values(cfg.sap).some((s) => s.role !== undefined);
553
+ const advisoryNotice = (capped) => formatAttention({
554
+ text: 'Write mode: advisory',
555
+ fact: `writes are proposed, never executed${capped}`,
556
+ // The config hint only makes sense when config CAUSED advisory.
557
+ next: capped === '' ? 'cspeach config set write_mode approval-gated' : undefined,
558
+ });
447
559
  if (!anyRoleConfigured) {
448
560
  if (cfg.write_mode === 'advisory-only') {
449
- console.log(chalk.yellow.bold('⚠ Write mode: ADVISORY (writes proposed, never executed)'));
450
- console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
561
+ if (getLook() === 'panels')
562
+ startupNotices.push(advisoryNotice(''));
563
+ else
564
+ console.log(advisoryNotice(''));
451
565
  }
452
- else {
566
+ else if (getLook() !== 'panels') {
453
567
  console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
454
568
  }
455
569
  }
@@ -460,15 +574,28 @@ export async function runRepl(opts) {
460
574
  // visibly: the toggle is persisted, not re-consented each session, so the user
461
575
  // must see that local file access + command execution are live this session.
462
576
  // Printed to stdout here (before any Ink frame mounts).
577
+ // 2026-09-26 — panels folds this announce into the one status line below.
578
+ // 2026-09-26 (I1) — the panels status line must say WHICH tools are live:
579
+ // local_build = file tools + shell command execution; standalone alone =
580
+ // file tools writing to ./cspeach-out.
581
+ let localTools = 'none';
463
582
  {
464
583
  const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
465
- if (announce)
466
- console.log(chalk.dim(announce + '\n'));
467
- // 2026-09-17 — standalone writes its outputs to files by contract, so the
468
- // file tools must be on without a separate local_build step (the RAP
469
- // stack landed in the transcript with "There are no files" otherwise).
470
- else if (standalone)
471
- console.log(chalk.dim(applyStandaloneFileTools(process.cwd()) + '\n'));
584
+ localTools = announce ? 'files+commands' : standalone ? 'files' : 'none';
585
+ if (getLook() === 'panels') {
586
+ // The side effect (turning the file tools on) must still happen.
587
+ if (!announce && standalone)
588
+ applyStandaloneFileTools(process.cwd());
589
+ }
590
+ else {
591
+ if (announce)
592
+ console.log(chalk.dim(announce + '\n'));
593
+ // 2026-09-17 — standalone writes its outputs to files by contract, so the
594
+ // file tools must be on without a separate local_build step (the RAP
595
+ // stack landed in the transcript with "There are no files" otherwise).
596
+ else if (standalone)
597
+ console.log(chalk.dim(applyStandaloneFileTools(process.cwd()) + '\n'));
598
+ }
472
599
  }
473
600
  const aliases = Object.keys(cfg.sap);
474
601
  // C.1 — --sap-alias flag override skips the interactive picker
@@ -479,13 +606,17 @@ export async function runRepl(opts) {
479
606
  selectedAlias = STANDALONE_ALIAS;
480
607
  }
481
608
  else if (opts?.sapAlias) {
482
- console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
609
+ const note = aliasSourceNote('flag', opts.sapAlias);
610
+ if (note !== null)
611
+ console.log(note);
483
612
  selectedAlias = opts.sapAlias;
484
613
  }
485
614
  else if (opts?.resumedSession?.sap_system) {
486
615
  // v0.6 — resumed session knows its SAP. Skip the picker.
487
616
  selectedAlias = opts.resumedSession.sap_system;
488
- console.log(chalk.dim(`Resumed on SAP ${selectedAlias}`));
617
+ const note = aliasSourceNote('resume', selectedAlias);
618
+ if (note !== null)
619
+ console.log(note);
489
620
  }
490
621
  else {
491
622
  selectedAlias =
@@ -507,25 +638,35 @@ export async function runRepl(opts) {
507
638
  ? ` (capped by sap.${selectedAlias}.role = ${aliasRole})`
508
639
  : '';
509
640
  if (startupWriteMode === 'advisory-only') {
510
- console.log(chalk.yellow.bold(`⚠ Write mode: ADVISORY (writes proposed, never executed)${capped}`));
511
- // The config hint only makes sense when config CAUSED advisory —
512
- // a role-capped advisory can't be lifted by a config change.
513
- if (capped === '') {
514
- console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
515
- }
516
- else {
517
- console.log('');
518
- }
641
+ // A role-capped advisory can't be lifted by a config change (no Next:).
642
+ if (getLook() === 'panels')
643
+ startupNotices.push(advisoryNotice(capped));
644
+ else
645
+ console.log(advisoryNotice(capped));
519
646
  }
520
- else {
647
+ else if (getLook() !== 'panels') {
521
648
  console.log(chalk.gray(`Write mode: ${startupWriteMode}${capped}\n`));
522
649
  }
523
650
  }
524
651
  // Standalone: the offline stand-in rejects every call with an actionable
525
652
  // message. This is the single construction site the AdtClient cast lives at.
526
- const adt = standalone
527
- ? createOfflineAdtClient()
528
- : new AdtClient(await getConnection(selectedAlias, cfg.sap[selectedAlias]));
653
+ let adt;
654
+ if (standalone) {
655
+ adt = createOfflineAdtClient();
656
+ }
657
+ else {
658
+ try {
659
+ adt = new AdtClient(await getConnection(selectedAlias, cfg.sap[selectedAlias]));
660
+ }
661
+ catch (err) {
662
+ // Dry run 2026-09-25: unreachable SAP host → one clear line, not a stack.
663
+ const code = unreachableCode(err);
664
+ if (!code)
665
+ throw err;
666
+ console.error(unreachableMessage(selectedAlias, cfg.sap[selectedAlias].host, cfg.sap[selectedAlias].port, code));
667
+ return 1;
668
+ }
669
+ }
529
670
  // Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
530
671
  // Push system + user the moment we know the alias. Before this commit
531
672
  // the sidebar showed "no system / user: —" forever because the store
@@ -541,15 +682,38 @@ export async function runRepl(opts) {
541
682
  system: standalone ? 'standalone' : selectedAlias,
542
683
  user: standalone ? '' : cfg.sap[selectedAlias].username,
543
684
  writeMode: startupWriteMode,
685
+ standalone,
544
686
  });
545
687
  // v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
546
688
  // preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
547
689
  // turnInterrupted flag from the prior run. Skipping newSession() avoids
548
690
  // overwriting the saved file with an empty fresh one.
549
691
  const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, resolveModelRole('session_default', cfg));
550
- if (!opts?.resumedSession) {
551
- await saveSession(session);
552
- }
692
+ // Round 6 item 4c — a resumed session keeps its skill for the first plain prompt.
693
+ let resumeKeepsSkill = Boolean(opts?.resumedSession?.skill);
694
+ // Round 7 item 8c — the footer shows a resumed session's spend at once.
695
+ if (opts?.resumedSession)
696
+ seedFooterSpend(session);
697
+ // Round 6 item 4b — a new session is written by its first turn (the loop
698
+ // saves after every round); a start-then-quit no longer leaves a 0-turn file.
699
+ // Round 6 item 2 + review I3 — an explicit --sap-alias moved a resumed
700
+ // session (standalone → SAP, or to another SAP system): update + save it.
701
+ await applySystemSwitch({
702
+ session,
703
+ decision: { ...standaloneDecision, standalone },
704
+ selectedAlias,
705
+ save: saveSession,
706
+ notify: (line) => {
707
+ if (getLook() === 'panels')
708
+ startupNotices.push(line);
709
+ else
710
+ console.log(line.trimEnd());
711
+ },
712
+ });
713
+ // Live fix 2026-09-26 — notices cut to one screen line keep their full text here.
714
+ // Review M6: sanitised id; notice logs older than 14 days are collected.
715
+ pruneNoticeLogs(logsDir());
716
+ setNoticeLogPath(noticeLogPathFor(logsDir(), session.id));
553
717
  // License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
554
718
  // inference, so without this anyone who never logged in could use the bundled
555
719
  // skills untracked. Require a valid CSPeach login before the REPL starts.
@@ -569,30 +733,63 @@ export async function runRepl(opts) {
569
733
  throw new Error('No API key configured');
570
734
  return key;
571
735
  });
736
+ // Panels status line — writeMode is the same EFFECTIVE (role-clamped) mode
737
+ // the legacy line prints.
738
+ const panelsStatus = {
739
+ system: selectedAlias,
740
+ standalone,
741
+ tools: localTools,
742
+ writeMode: startupWriteMode,
743
+ cwd: process.cwd(),
744
+ };
572
745
  if (opts?.resumedSession) {
573
746
  const turnCount = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
574
- console.log(chalk.cyan(`\nResumed session ${session.id} on ${selectedAlias} — ${turnCount} prior turn(s).`));
575
- if (session.turnInterrupted) {
576
- const reason = session.turnInterruptedReason ?? 'unknown';
577
- console.log(chalk.yellow(`⚠ Last turn was interrupted (${reason}).`));
578
- console.log(chalk.yellow(` Type \"continue\" to ask the model to finish where it left off.`));
579
- }
580
- // Task 6 (ux-wave2) — session recap: three dim lines (last session ·
581
- // open todos · unsatisfied plan) so the returning developer is oriented
582
- // without scrolling. Covers BOTH resume entries — the `cspeach --resume`
583
- // picker (session/resume.ts) and any direct --resume <id> — since both
584
- // land here with opts.resumedSession set. Best-effort: buildRecap never
585
- // throws, and a workspace-resolution failure just drops the plan line.
586
- try {
587
- const { buildRecap } = await import('./session/recap.js');
588
- const { resolveWorkspacePath } = await import('./projects/workspace.js');
589
- const recapWs = await resolveWorkspacePath().catch(() => undefined);
590
- for (const recapLine of buildRecap(session, process.cwd(), { workspaceDir: recapWs })) {
591
- console.log(chalk.dim(recapLine));
747
+ // Task 6 (ux-wave2) — session recap (last session · open todos ·
748
+ // unsatisfied plan) so the returning developer is oriented without
749
+ // scrolling. Covers BOTH resume entries — the `cspeach --resume` picker
750
+ // (session/resume.ts) and any direct --resume <id> — since both land here
751
+ // with opts.resumedSession set. Best-effort: the recap never throws, and a
752
+ // workspace-resolution failure just drops the plan fact.
753
+ if (getLook() === 'panels') {
754
+ let recap = { last: null, tasks: null, plan: null };
755
+ try {
756
+ const { buildRecapParts } = await import('./session/recap.js');
757
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
758
+ const recapWs = await resolveWorkspacePath().catch(() => undefined);
759
+ recap = buildRecapParts(session, process.cwd(), { workspaceDir: recapWs });
760
+ }
761
+ catch { /* recap is best-effort — never block resume */ }
762
+ // Piece 2 §10: same three-line shape. Open tasks show in the checklist
763
+ // (restored from session.todos), so only the plan becomes a "!" line.
764
+ if (session.turnInterrupted) {
765
+ // Round 7 item 8d — a stop the user asked for is not an error.
766
+ startupNotices.push(isUserStopReason(session.turnInterruptedReason)
767
+ ? formatAttention({ text: 'Last turn was stopped by you', next: 'type continue to finish where it left off' })
768
+ : formatAttention({ text: 'Last turn was interrupted', fact: readableInterruptReason(session.turnInterruptedReason), next: 'type continue to finish where it left off' }));
769
+ }
770
+ if (recap.plan)
771
+ startupNotices.push(formatAttention({ text: `Unfinished plan ${recap.plan.text}`, next: recap.plan.next }));
772
+ process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsResumeLine(recap.last, turnCount), notices: startupNotices }));
773
+ }
774
+ else {
775
+ console.log(chalk.cyan(`\nResumed session ${session.id} on ${selectedAlias} — ${turnCount} prior turn(s).`));
776
+ if (session.turnInterrupted) {
777
+ console.log(formatAttention({ text: isUserStopReason(session.turnInterruptedReason) ? 'Last turn was stopped by you' : `Last turn was interrupted (${readableInterruptReason(session.turnInterruptedReason)})`, next: 'type "continue" to ask the model to finish where it left off' }).trimEnd());
592
778
  }
779
+ try {
780
+ const { buildRecap } = await import('./session/recap.js');
781
+ const { resolveWorkspacePath } = await import('./projects/workspace.js');
782
+ const recapWs = await resolveWorkspacePath().catch(() => undefined);
783
+ for (const recapLine of buildRecap(session, process.cwd(), { workspaceDir: recapWs })) {
784
+ console.log(chalk.dim(recapLine));
785
+ }
786
+ }
787
+ catch { /* recap is best-effort — never block resume */ }
788
+ console.log(chalk.gray(`Type /exit to quit.\n`));
593
789
  }
594
- catch { /* recap is best-effort — never block resume */ }
595
- console.log(chalk.gray(`Type /exit to quit.\n`));
790
+ }
791
+ else if (getLook() === 'panels') {
792
+ process.stdout.write(panelsStartTail({ status: panelsStatus, third: panelsHintLine(localTools), notices: startupNotices }));
596
793
  }
597
794
  else {
598
795
  console.log(chalk.gray(`\nSession ${session.id} on ${selectedAlias}. Type /exit to quit.\n`));
@@ -625,6 +822,8 @@ export async function runRepl(opts) {
625
822
  const { App } = await import('./ui/app.js');
626
823
  const React = (await import('react')).default;
627
824
  const chunkEmitter = new EventEmitter();
825
+ setTraceEmitter(chunkEmitter); // piece 2 §1.5 — answered/decided traces land in <Static>
826
+ const inkPreview = inkPreviewHook(chunkEmitter); // piece 2 §6.3 — Ink sites: diff to scrollback + Apply/Cancel card
628
827
  // Phase D6 (2026-05-17) — INLINE Ink mode (no alt-screen).
629
828
  //
630
829
  // After Laeeq pointed out CC achieves great scrolling without any
@@ -674,6 +873,50 @@ export async function runRepl(opts) {
674
873
  // it. When null, we're at idle and should exit on Ctrl+C.
675
874
  let currentTurnAbort = null;
676
875
  let interruptCount = 0;
876
+ // Review I2/I3 — a cancel asked for before the turn has its controller
877
+ // (routing / prep). Applied at controller creation; cleared when the
878
+ // dispatch ends without a turn.
879
+ const pendingCancel = createPendingTurnCancel();
880
+ // Re-review 2/3 — where a busy dispatch without a controller is, so the
881
+ // cancel line says only what will actually happen. postTurn: the turn's
882
+ // controller has been cleared (saving, audit, next-phase offer);
883
+ // postTurnMayChain: that post-turn work may auto-start a plan phase.
884
+ let postTurn = false;
885
+ let postTurnMayChain = false;
886
+ let cancelAnnouncedPhase = null;
887
+ /** Round 4 item 5 — the in-flight classifier request, while routing. */
888
+ let routingAbort = null;
889
+ const cancelPhase = () => turnStatusEmitter.snapshot().routing ? 'routing' : postTurn ? 'post' : 'prep';
890
+ const canChainNow = () => postTurnMayChain || pendingDispatchAccessor.get() !== null;
891
+ /** Queue a cancel; print its one truthful line the first time. */
892
+ const announcePendingCancel = (exiting) => {
893
+ if (!pendingCancel.request())
894
+ return;
895
+ const phase = cancelPhase();
896
+ cancelAnnouncedPhase = phase;
897
+ // Round 4 item 5 — stop the classifier request (and its spinner) now.
898
+ routingAbort?.abort();
899
+ const line = phase === 'post' && !canChainNow()
900
+ ? 'Exiting when this turn has finished saving'
901
+ // Round 4 item 9a — name what is queued (a skill switch is not a plan phase).
902
+ : pendingCancelLine(phase, exiting, queuedWorkLabel(pendingDispatchAccessor.get()));
903
+ chunkEmitter.emit('chunk', '\n' + formatInfo(line));
904
+ };
905
+ /** Auto-dispatch of the next plan phase, unless a post-turn cancel stopped it. */
906
+ const autoDispatch = (cmd) => {
907
+ // Round 4 item 9b — the exit is already under way: start nothing new.
908
+ // Final review B-I2 — `/exit` typed mid-turn (exitAfterTurn) stops it too.
909
+ const decision = autoDispatchDecision({ done, exitRequested: exitAfterTurn, cancelPending: pendingCancel.pending }, cmd);
910
+ if (decision.kind === 'skip')
911
+ return;
912
+ if (decision.kind === 'not-started') {
913
+ pendingCancel.clear();
914
+ chunkEmitter.emit('chunk', formatInfo(decision.line));
915
+ return;
916
+ }
917
+ chunkEmitter.emit('chunk', formatInfo(`Auto-routed: ${cmd}`));
918
+ setImmediate(() => { void handleSubmit(cmd); });
919
+ };
677
920
  // BUG-2b — the actual dispatch. Wrapped by handleSubmit (below) which
678
921
  // owns the single-flight reserve()/release() lifecycle. All re-entrant
679
922
  // callers (the setImmediate auto-dispatch / re-audit legs) go through the
@@ -682,6 +925,10 @@ export async function runRepl(opts) {
682
925
  const trimmed = line.trim();
683
926
  if (!trimmed)
684
927
  return;
928
+ // Round 6 review I2 — the resumed skill is offered to the FIRST submit
929
+ // only (slash commands included), then cleared.
930
+ const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
931
+ resumeKeepsSkill = false;
685
932
  // Task 8 (ux-wave1) — shared dedup-guarded writer. On the Ink path
686
933
  // the Footer has already persisted this submission (flattened), so
687
934
  // the consecutive-dup guard makes this a no-op instead of the old
@@ -694,8 +941,7 @@ export async function runRepl(opts) {
694
941
  // new turn's content touched with no separator and looked like
695
942
  // garbled overlap).
696
943
  if (trimmed !== '/exit' && trimmed !== '/quit') {
697
- chunkEmitter.emit('chunk', chalk.dim('\n──────────────────────────────────\n') +
698
- chalk.cyan(`› ${trimmed}\n`));
944
+ chunkEmitter.emit('chunk', formatPromptEcho(trimmed, getEffectiveBodyWidth()));
699
945
  }
700
946
  if (trimmed === '/exit' || trimmed === '/quit') {
701
947
  done = true;
@@ -719,7 +965,9 @@ export async function runRepl(opts) {
719
965
  // from the JSONL log written each turn. Read-only, no network.
720
966
  if (trimmed === '/cost') {
721
967
  const { runCostCommand } = await import('./commands/cost.js');
722
- await runCostCommand(session.id);
968
+ // Piece 2: through the chunkEmitter (like /context and /export) — a
969
+ // console.log here would land above the Ink frame.
970
+ await runCostCommand(session.id, undefined, (l) => chunkEmitter.emit('chunk', l + '\n'));
723
971
  return;
724
972
  }
725
973
  // Task 4 (ux-wave3) — /export audit [filename]: write the signable
@@ -764,7 +1012,7 @@ export async function runRepl(opts) {
764
1012
  if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
765
1013
  const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
766
1014
  const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
767
- chunkEmitter.emit('chunk', chalk.dim(modeLine) + '\n');
1015
+ chunkEmitter.emit('chunk', formatInfo(modeLine));
768
1016
  sapStateStore.update({ writeMode: mode });
769
1017
  return;
770
1018
  }
@@ -777,8 +1025,8 @@ export async function runRepl(opts) {
777
1025
  const recapWs = await resolveWorkspacePath().catch(() => undefined);
778
1026
  const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
779
1027
  const body = recapLines.length === 0
780
- ? chalk.dim('nothing to recap yet')
781
- : recapLines.map((l) => chalk.dim(l)).join('\n');
1028
+ ? formatInfo('Nothing to recap yet').trimEnd()
1029
+ : formatPanel('Recap', recapLines.map((l) => ' ' + paint('dim', sanitizeControlChars(l)))).join('\n');
782
1030
  chunkEmitter.emit('chunk', '\n' + body + '\n');
783
1031
  return;
784
1032
  }
@@ -797,12 +1045,12 @@ export async function runRepl(opts) {
797
1045
  { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
798
1046
  };
799
1047
  try {
800
- chunkEmitter.emit('chunk', chalk.dim('\nCompacting older turns into a summary...\n'));
1048
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Compacting older turns into a summary…'));
801
1049
  const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
802
1050
  chunkEmitter.emit('chunk', formatCompactResult(result, compactCfg.keep_recent_turns));
803
1051
  }
804
1052
  catch (err) {
805
- chunkEmitter.emit('chunk', chalk.red(`\n/compact failed: ${err instanceof Error ? err.message : String(err)}\n`));
1053
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Compact failed', detail: [err instanceof Error ? err.message : String(err)] }));
806
1054
  }
807
1055
  return;
808
1056
  }
@@ -812,9 +1060,9 @@ export async function runRepl(opts) {
812
1060
  if (trimmed === '/files') {
813
1061
  try {
814
1062
  const { openFilePicker } = await import('./repl/file-picker.js');
815
- const result = await openFilePicker();
1063
+ const result = await openFilePicker({ ink: true });
816
1064
  if (result.kind === 'command') {
817
- chunkEmitter.emit('chunk', chalk.dim(`↪ ${result.command}\n`));
1065
+ chunkEmitter.emit('chunk', formatInfo(result.command));
818
1066
  }
819
1067
  else if (result.kind === 'empty') {
820
1068
  // Empty workspace — print the listing so the user sees the
@@ -828,7 +1076,7 @@ export async function runRepl(opts) {
828
1076
  // result.kind === 'cancelled' — print nothing.
829
1077
  }
830
1078
  catch (err) {
831
- chunkEmitter.emit('chunk', chalk.red(`\n[/files] ${err.message}\n`));
1079
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not open the file list', detail: [err.message] }));
832
1080
  }
833
1081
  return;
834
1082
  }
@@ -837,32 +1085,12 @@ export async function runRepl(opts) {
837
1085
  // lands in Body, not above the React frame.
838
1086
  if (trimmed === '/ui' || trimmed.startsWith('/ui ')) {
839
1087
  if (!cfg.ui)
840
- cfg.ui = { rendering: 'auto' };
1088
+ cfg.ui = { rendering: 'auto', look: DEFAULT_CONFIG.ui.look };
841
1089
  const arg = trimmed.slice(3).trim().toLowerCase();
842
1090
  const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
843
- if (arg === '') {
844
- emit('');
845
- emit(`Current UI mode: ${chalk.cyan.bold(cfg.ui.rendering)} ` +
846
- `(effective now: ${chalk.yellow(shouldUseInk() ? 'ink' : 'classic')})`);
847
- emit(chalk.dim(' /ui classic — chalk REPL (default)'));
848
- emit(chalk.dim(' /ui ink — Ink frame (opt-in)'));
849
- emit(chalk.dim(' /ui auto — choose automatically'));
850
- emit(chalk.dim('Change takes effect after /exit + relaunch.'));
851
- }
852
- else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
853
- cfg.ui.rendering = arg;
854
- try {
855
- await saveConfig(cfg);
856
- emit(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
857
- emit(chalk.dim('Relaunch cspeach to apply.'));
858
- }
859
- catch (err) {
860
- emit(chalk.red(`\n✗ Failed to save config: ${err.message}`));
861
- }
862
- }
863
- else {
864
- emit(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.`));
865
- }
1091
+ if (await handleUiLook(arg, cfg, saveConfig, emit))
1092
+ return;
1093
+ await handleUiRendering(arg, cfg, saveConfig, emit, shouldUseInk() ? 'ink' : 'classic');
866
1094
  return;
867
1095
  }
868
1096
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
@@ -874,23 +1102,41 @@ export async function runRepl(opts) {
874
1102
  return;
875
1103
  }
876
1104
  const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
877
- chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
1105
+ chunkEmitter.emit('chunk', '\n' + formatInfo(`Rerouting "${preview}" to /${rr.skill}`));
878
1106
  if (rr.warning)
879
1107
  chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
1108
+ // Re-review 1 — its own AbortController, wired like the main path, so
1109
+ // Ctrl+C and `/exit` stop a rerouted turn the same way.
1110
+ const rerouteAbort = new AbortController();
1111
+ currentTurnAbort = rerouteAbort;
1112
+ interruptCount = 0;
1113
+ if (pendingCancel.applyTo(rerouteAbort))
1114
+ interruptCount = 1;
880
1115
  turnStatusEmitter.start(`/${rr.skill}`);
881
- try {
882
- await runTurn({
1116
+ // A turn error is rendered into the conversation like the normal turn
1117
+ // path (never an unhandled rejection out of `void handleSubmit`).
1118
+ await runRerouteTurn({
1119
+ run: () => runTurn({
883
1120
  provider,
884
1121
  userMessage: rr.prompt,
885
1122
  skill: rr.skill,
886
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1123
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
887
1124
  chunkEmitter,
888
1125
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
889
- });
890
- }
891
- finally {
892
- turnStatusEmitter.stop();
893
- }
1126
+ signal: rerouteAbort.signal,
1127
+ }),
1128
+ session,
1129
+ print: (line) => chunkEmitter.emit('chunk', line + '\n'),
1130
+ signal: rerouteAbort.signal,
1131
+ onFinally: () => {
1132
+ turnStatusEmitter.stop();
1133
+ currentTurnAbort = null;
1134
+ postTurn = true;
1135
+ // P2 fix round 1 — /reroute skips pushPostTurnStatus, so run its
1136
+ // turn-end plan step here (close line / 'close' when finished).
1137
+ emitPlanTurnEnd(session);
1138
+ },
1139
+ });
894
1140
  return;
895
1141
  }
896
1142
  // /new — clear any "awaiting skill answer" state so the next prompt
@@ -898,7 +1144,11 @@ export async function runRepl(opts) {
898
1144
  // previous skill's Q&A chain.
899
1145
  if (trimmed === '/new' || trimmed === '/reset') {
900
1146
  session.awaitingSkillAnswer = false;
901
- chunkEmitter.emit('chunk', chalk.dim('\n↩ fresh start — next prompt will be classified normally\n'));
1147
+ // P2 — /new clears the plan checklist (spec §4 lifecycle): the session
1148
+ // list AND the live view, so Ctrl+T / --resume can't bring it back.
1149
+ session.todos = [];
1150
+ todoEmitter.emit('update', []);
1151
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Fresh start · the next prompt picks a skill again'));
902
1152
  return;
903
1153
  }
904
1154
  // /transport — session current-transport state for Rule 9 (transport isolation)
@@ -990,63 +1240,119 @@ export async function runRepl(opts) {
990
1240
  chalk.dim('Type a new question, /new to reset, or /exit to quit.\n'));
991
1241
  return;
992
1242
  }
993
- if ((session.awaitingSkillAnswer || isAckContinuation(trimmed, session.skill)) && session.skill) {
994
- chunkEmitter.emit('chunk', chalk.dim(`↪ Continuing /${session.skill}\n`));
1243
+ // Round 6 item 4c / review I2 — after a resume, a short follow-up
1244
+ // ("continue", a number) stays in the session's skill; anything else
1245
+ // is classified (applyResumedSkill below).
1246
+ if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
1247
+ chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'continuing', skill: session.skill }));
995
1248
  skill = session.skill;
996
1249
  }
997
1250
  else if (isBareAck(trimmed)) {
998
1251
  // Bare "y" / "n" / "cancel" with no skill to continue — typing this
999
1252
  // after an aborted turn is almost always a stray keystroke, NOT a
1000
1253
  // request to start work. Don't burn a classifier round-trip on it.
1001
- chunkEmitter.emit('chunk', chalk.dim('↩ no active skill — type /help or a full prompt\n'));
1254
+ chunkEmitter.emit('chunk', formatInfo('No active skill · type /help or a full prompt'));
1002
1255
  return;
1003
1256
  }
1004
1257
  else {
1005
1258
  try {
1006
- const c = await classifyPrompt(prompt, {
1007
- sap_system: selectedAlias,
1008
- last_skill: session.last_skill ?? null,
1009
- last_object: extractedObject ?? session.last_object ?? null,
1010
- });
1011
- if (c.offline) {
1012
- // Remote classify failed twice — local-rules fallback fired.
1013
- // Tell the user so they know the picker preselection came
1014
- // from regex, not the LLM, and surface the real cause.
1015
- chunkEmitter.emit('chunk', chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]\n`));
1259
+ // Fix round 3 (item 8) — the status row reads "routing…" for
1260
+ // the whole classifier wait (was a blank screen for up to 16 s).
1261
+ turnStatusEmitter.routingStart();
1262
+ // Round 4 item 5 — Ctrl+C during routing aborts the classifier
1263
+ // request at once (the spinner used to run until it answered,
1264
+ // up to ~17 s). A cancel queued before routing started aborts
1265
+ // it before any request is made.
1266
+ const routingCtl = new AbortController();
1267
+ routingAbort = routingCtl;
1268
+ if (pendingCancel.pending)
1269
+ routingCtl.abort();
1270
+ let c;
1271
+ try {
1272
+ c = await classifyPrompt(prompt, {
1273
+ sap_system: selectedAlias,
1274
+ last_skill: session.last_skill ?? null,
1275
+ last_object: extractedObject ?? session.last_object ?? null,
1276
+ }, { signal: routingCtl.signal });
1016
1277
  }
1017
- if (c.screenshot) {
1018
- chunkEmitter.emit('chunk', chalk.dim(`┄ Screenshot: ${c.screenshot}
1019
- `));
1278
+ catch (err) {
1279
+ if (!routingCtl.signal.aborted)
1280
+ throw err;
1020
1281
  }
1021
- const decision = decideRouting(c);
1022
- // 2026-05-17 — pass the just-used skill so the picker can
1023
- // default cursor to it (one-Enter "continue" UX for follow-up
1024
- // questions). session.skill = immediately-previous turn's
1025
- // skill, NOT session.last_skill which is from TWO turns ago.
1026
- const continueSkill = session.skill ?? null;
1027
- let pickedResult;
1028
- if (cfg.classifier.safe_mode) {
1029
- pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
1282
+ finally {
1283
+ routingAbort = null;
1284
+ turnStatusEmitter.routingEnd();
1030
1285
  }
1031
- else if (decision.kind === 'silent') {
1032
- chunkEmitter.emit('chunk', chalk.dim(`┄ Routed to /${decision.skill}\n`));
1033
- pickedResult = decision.skill;
1286
+ // Review I2/I3 — cancelled while routing: never start the turn.
1287
+ // One line only (re-review 3): the press already printed it when
1288
+ // it came during routing.
1289
+ if (pendingCancel.pending || c === undefined) {
1290
+ pendingCancel.clear();
1291
+ if (cancelAnnouncedPhase !== 'routing') {
1292
+ chunkEmitter.emit('chunk', formatInfo('Turn cancelled · nothing was sent'));
1293
+ }
1294
+ return;
1295
+ }
1296
+ if (c.screenshot) {
1297
+ chunkEmitter.emit('chunk', formatInfo(`Screenshot: ${c.screenshot}`));
1034
1298
  }
1035
- else if (decision.kind === 'coach') {
1036
- pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true, continueSkill));
1299
+ // Fix 10 (2026-09-26): routing failed mid-skill → stay in the
1300
+ // active skill (one dim line), never the picker. Shared decision
1301
+ // with the classic path (routingFailureOutcome).
1302
+ const failed = routingFailureOutcome(routingFailureFromResult(c), session.skill);
1303
+ if (failed.kind === 'stay') {
1304
+ chunkEmitter.emit('chunk', formatInfo(failed.line));
1305
+ skill = failed.skill;
1037
1306
  }
1038
1307
  else {
1039
- pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false, continueSkill));
1308
+ if (c.offline) {
1309
+ // Remote classify failed twice — local-rules fallback fired.
1310
+ // Tell the user so they know the picker preselection came
1311
+ // from regex, not the LLM, and surface the real cause.
1312
+ // Fix 9: true wording from the failure kind (a timeout is not "offline").
1313
+ chunkEmitter.emit('chunk', formatAttention(routingFailureNotice(routingFailureFromResult(c), c.transport_error)));
1314
+ }
1315
+ // Round 6 review I2 — after a resume the classifier's confident pick
1316
+ // wins; the resumed skill replaces only the "Pick a skill" card.
1317
+ const decision = resumedSkill ? applyResumedSkill(decideRouting(c), resumedSkill) : decideRouting(c);
1318
+ // 2026-05-17 — pass the just-used skill so the picker can
1319
+ // default cursor to it (one-Enter "continue" UX for follow-up
1320
+ // questions). session.skill = immediately-previous turn's
1321
+ // skill, NOT session.last_skill which is from TWO turns ago.
1322
+ const continueSkill = session.skill ?? null;
1323
+ let pickedResult;
1324
+ if (cfg.classifier.safe_mode) {
1325
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
1326
+ }
1327
+ else if (decision.kind === 'silent') {
1328
+ chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'using', skill: decision.skill }));
1329
+ pickedResult = decision.skill;
1330
+ }
1331
+ else if (decision.kind === 'coach') {
1332
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true, continueSkill));
1333
+ }
1334
+ else {
1335
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false, continueSkill));
1336
+ }
1337
+ if (pickedResult === PICK_CANCELLED) {
1338
+ chunkEmitter.emit('chunk', formatRoutingLine({ kind: 'cancelled' }));
1339
+ return;
1340
+ }
1341
+ skill = pickedResult;
1342
+ } // end fix-10 fallback branch
1343
+ }
1344
+ catch (err) {
1345
+ const failure = routingFailureFromError(err);
1346
+ const failed = routingFailureOutcome(failure, session.skill);
1347
+ if (failed.kind === 'stay') {
1348
+ // Fix 10: HTTP routing failure mid-skill → stay in the active skill.
1349
+ chunkEmitter.emit('chunk', formatInfo(failed.line));
1350
+ skill = failed.skill;
1040
1351
  }
1041
- if (pickedResult === PICK_CANCELLED) {
1042
- chunkEmitter.emit('chunk', chalk.dim('↩ cancelled — type a new prompt\n'));
1352
+ else {
1353
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: failure ? routingFailureText(failure) : 'Could not pick a skill for this prompt', detail: [err.message], next: 'start the prompt with a skill, e.g. /abap-explain' }));
1043
1354
  return;
1044
1355
  }
1045
- skill = pickedResult;
1046
- }
1047
- catch (err) {
1048
- chunkEmitter.emit('chunk', chalk.red(`\n[classifier error] ${err.message}\n`));
1049
- return;
1050
1356
  }
1051
1357
  }
1052
1358
  }
@@ -1076,7 +1382,7 @@ export async function runRepl(opts) {
1076
1382
  });
1077
1383
  }
1078
1384
  catch (err) {
1079
- chunkEmitter.emit('chunk', chalk.red(`\n[--status] ${err.message}\n`));
1385
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not show the status', detail: [err.message] }));
1080
1386
  }
1081
1387
  session.last_skill = previousSkill ?? null;
1082
1388
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -1108,7 +1414,7 @@ export async function runRepl(opts) {
1108
1414
  if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
1109
1415
  try {
1110
1416
  const { preparePlanResume } = await import('./commands/plan-resume.js');
1111
- const { inkAwarePrompt } = await import('./agent/loop.js');
1417
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1112
1418
  planResume = await preparePlanResume({
1113
1419
  body: prompt,
1114
1420
  cwd: process.cwd(),
@@ -1120,11 +1426,12 @@ export async function runRepl(opts) {
1120
1426
  // resume guard; inkAwarePrompt answers '' in headless = decline).
1121
1427
  sessionId: session.id,
1122
1428
  prompt: inkAwarePrompt,
1429
+ confirm: inkAwareConfirm,
1123
1430
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1124
1431
  });
1125
1432
  }
1126
1433
  catch (err) {
1127
- chunkEmitter.emit('chunk', chalk.red(`\n[--resume] ${err.message}\n`));
1434
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
1128
1435
  }
1129
1436
  if (!planResume) {
1130
1437
  session.last_skill = previousSkill ?? null;
@@ -1139,7 +1446,7 @@ export async function runRepl(opts) {
1139
1446
  if (planResume.reauditPhaseId) {
1140
1447
  try {
1141
1448
  const { runReauditPass } = await import('./commands/plan-chain.js');
1142
- const { inkAwarePrompt } = await import('./agent/loop.js');
1449
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1143
1450
  await runReauditPass({
1144
1451
  savedPath: planResume.path,
1145
1452
  phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
@@ -1152,12 +1459,13 @@ export async function runRepl(opts) {
1152
1459
  auditMode: planResume.auditMode,
1153
1460
  auditFlag: planResume.auditFlag,
1154
1461
  prompt: inkAwarePrompt,
1462
+ confirm: inkAwareConfirm,
1155
1463
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1156
1464
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1157
1465
  });
1158
1466
  }
1159
1467
  catch (err) {
1160
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] re-audit failed: ${err.message}\n`));
1468
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan re-audit failed', detail: [err.message] }));
1161
1469
  }
1162
1470
  session.last_skill = previousSkill ?? null;
1163
1471
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -1175,8 +1483,7 @@ export async function runRepl(opts) {
1175
1483
  }
1176
1484
  if (reauditDispatched) {
1177
1485
  pendingDispatchAccessor.set(null);
1178
- chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${reauditDispatched}\n`));
1179
- setImmediate(() => { void handleSubmit(reauditDispatched); });
1486
+ autoDispatch(reauditDispatched);
1180
1487
  }
1181
1488
  return;
1182
1489
  }
@@ -1188,7 +1495,7 @@ export async function runRepl(opts) {
1188
1495
  {
1189
1496
  const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
1190
1497
  if (resetContextForPlanResume(session)) {
1191
- chunkEmitter.emit('chunk', chalk.dim('↪ Plan phase starts in a fresh bounded context — prior transcript cleared\n'));
1498
+ chunkEmitter.emit('chunk', formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared'));
1192
1499
  }
1193
1500
  }
1194
1501
  planResumeMsgStart = session.messages.length;
@@ -1244,7 +1551,7 @@ export async function runRepl(opts) {
1244
1551
  planContinue = { prepared: r.prepared, marker };
1245
1552
  planPhaseStartedAt = marker.attemptStartedAt;
1246
1553
  planResumeMsgStart = session.messages.length;
1247
- chunkEmitter.emit('chunk', chalk.dim(`↪ re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})\n`));
1554
+ chunkEmitter.emit('chunk', formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
1248
1555
  }
1249
1556
  else if (r.stale) {
1250
1557
  session.plan_in_flight = null; // plan/phase gone for good — marker is noise
@@ -1271,12 +1578,18 @@ export async function runRepl(opts) {
1271
1578
  // modes. (Pre-fix this whole block didn't exist in the Ink path —
1272
1579
  // sidebar stayed frozen at "0 tok / no turns yet" forever.)
1273
1580
  const turnSnapshot = snapshotTurnStart(session);
1581
+ // Round 6 item 4d — the user cancelled this turn (no receipt when it used no tokens).
1582
+ let turnCancelledByUser = false;
1274
1583
  // Phase D5 (2026-05-17) — install per-turn abort. onInterrupt
1275
1584
  // (Ctrl+C handler) calls currentTurnAbort.abort() while this is
1276
1585
  // non-null, which tears the in-flight provider stream down via
1277
1586
  // the signal we thread into runTurn.
1278
1587
  currentTurnAbort = new AbortController();
1279
1588
  interruptCount = 0;
1589
+ // Review I2/I3 — `/exit` or Ctrl+C arrived during prep: stop the turn
1590
+ // at its very start (the press already said "Stopping…").
1591
+ if (pendingCancel.applyTo(currentTurnAbort))
1592
+ interruptCount = 1;
1280
1593
  // 2026-06-07 — persistent status row lifecycle. Started here (not in
1281
1594
  // loop.ts) so the try/finally GUARANTEES the row clears even when the
1282
1595
  // turn throws; loop.ts only narrates activity changes in between.
@@ -1308,7 +1621,7 @@ export async function runRepl(opts) {
1308
1621
  if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
1309
1622
  setGuardedRunActive(true);
1310
1623
  guardedRunFlagSet = true;
1311
- chunkEmitter.emit('chunk', chalk.dim('▶ guarded mode — auto_approve disabled for this run\n'));
1624
+ chunkEmitter.emit('chunk', formatInfo('Guarded mode · auto-approve is off for this run'));
1312
1625
  }
1313
1626
  // Off-switch (2026-07-06, owner) — announce plainly that this run's
1314
1627
  // phases are NOT audited (honesty: off is a choice, never a lie).
@@ -1320,7 +1633,7 @@ export async function runRepl(opts) {
1320
1633
  provider,
1321
1634
  userMessage: prompt,
1322
1635
  skill: skill,
1323
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1636
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1324
1637
  chunkEmitter,
1325
1638
  pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
1326
1639
  signal: currentTurnAbort.signal,
@@ -1346,7 +1659,7 @@ export async function runRepl(opts) {
1346
1659
  planSavedEarly = true;
1347
1660
  }
1348
1661
  catch (err) {
1349
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
1662
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
1350
1663
  }
1351
1664
  }
1352
1665
  : planContinueForHook
@@ -1373,7 +1686,7 @@ export async function runRepl(opts) {
1373
1686
  // owns the foreign save / quiet-none handling.
1374
1687
  }
1375
1688
  catch (err) {
1376
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] early continuation save failed: ${err.message}\n`));
1689
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
1377
1690
  }
1378
1691
  }
1379
1692
  : undefined,
@@ -1386,10 +1699,11 @@ export async function runRepl(opts) {
1386
1699
  // to do. Same block classic mode prints; in-session it leads with
1387
1700
  // "type continue" (the partial turn is already saved).
1388
1701
  const { renderTurnError } = await import('./agent/turn-error-ux.js');
1702
+ turnCancelledByUser = currentTurnAbort?.signal.aborted ?? false;
1389
1703
  await renderTurnError({
1390
1704
  err,
1391
1705
  session,
1392
- userInterrupted: currentTurnAbort?.signal.aborted ?? false,
1706
+ userInterrupted: turnCancelledByUser,
1393
1707
  inSession: true,
1394
1708
  print: (line) => chunkEmitter.emit('chunk', line + '\n'),
1395
1709
  });
@@ -1397,6 +1711,11 @@ export async function runRepl(opts) {
1397
1711
  finally {
1398
1712
  turnStatusEmitter.stop();
1399
1713
  currentTurnAbort = null;
1714
+ // Re-review 2 — from here on a Ctrl+C can only stop an automatic
1715
+ // next plan phase (and only when this is a plan turn or a dispatch
1716
+ // is already queued); otherwise it behaves as idle.
1717
+ postTurn = true;
1718
+ postTurnMayChain = planResume !== null || planContinue !== null;
1400
1719
  // Task 6/7/10 — neither the guarded auto_approve override, the
1401
1720
  // plan-phase enforcement flag, nor the phase-writes declaration may
1402
1721
  // outlive the phase turn. (The deviation flag DOES survive this
@@ -1439,7 +1758,7 @@ export async function runRepl(opts) {
1439
1758
  });
1440
1759
  }
1441
1760
  catch (err) {
1442
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
1761
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
1443
1762
  }
1444
1763
  }
1445
1764
  // A1 (defect D23) + Task 6 — harness-owned continuation, now the full
@@ -1452,7 +1771,7 @@ export async function runRepl(opts) {
1452
1771
  if (planResume && planOutcome) {
1453
1772
  try {
1454
1773
  const { runPostPhaseChain } = await import('./commands/plan-chain.js');
1455
- const { inkAwarePrompt } = await import('./agent/loop.js');
1774
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1456
1775
  await runPostPhaseChain({
1457
1776
  savedPath: planOutcome.savedPath,
1458
1777
  executedPhaseId: planResume.nextPhaseId,
@@ -1470,12 +1789,13 @@ export async function runRepl(opts) {
1470
1789
  // finally; thread the captured value for the report line.
1471
1790
  subagentDispatches: planSubagentDispatches,
1472
1791
  prompt: inkAwarePrompt,
1792
+ confirm: inkAwareConfirm,
1473
1793
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1474
1794
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1475
1795
  });
1476
1796
  }
1477
1797
  catch (err) {
1478
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] post-phase chain failed: ${err.message}\n`));
1798
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
1479
1799
  }
1480
1800
  }
1481
1801
  // C1 — continuation post-turn: fallback-persist (hook may not have
@@ -1514,13 +1834,13 @@ export async function runRepl(opts) {
1514
1834
  }
1515
1835
  }
1516
1836
  catch (err) {
1517
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] continuation save failed: ${err.message}\n`));
1837
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
1518
1838
  }
1519
1839
  }
1520
1840
  if (planContinue && planOutcome) {
1521
1841
  try {
1522
1842
  const { runContinuationChain } = await import('./commands/plan-continue.js');
1523
- const { inkAwarePrompt } = await import('./agent/loop.js');
1843
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
1524
1844
  await runContinuationChain({
1525
1845
  prepared: planContinue.prepared,
1526
1846
  marker: planContinue.marker,
@@ -1529,12 +1849,13 @@ export async function runRepl(opts) {
1529
1849
  cwd: process.cwd(),
1530
1850
  subagentDispatches: planSubagentDispatches,
1531
1851
  prompt: inkAwarePrompt,
1852
+ confirm: inkAwareConfirm,
1532
1853
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
1533
1854
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
1534
1855
  });
1535
1856
  }
1536
1857
  catch (err) {
1537
- chunkEmitter.emit('chunk', chalk.red(`\n[plan] continuation chain failed: ${err.message}\n`));
1858
+ chunkEmitter.emit('chunk', '\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
1538
1859
  }
1539
1860
  }
1540
1861
  // C1 — the phase reached a resolved terminal (revision persisted, chain
@@ -1565,6 +1886,7 @@ export async function runRepl(opts) {
1565
1886
  snapshot: turnSnapshot,
1566
1887
  chunkEmitter,
1567
1888
  turnModel: planModelOverride ?? session.model,
1889
+ cancelled: turnCancelledByUser,
1568
1890
  });
1569
1891
  session.last_skill = previousSkill ?? null;
1570
1892
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -1582,11 +1904,11 @@ export async function runRepl(opts) {
1582
1904
  // the transcript reset — it runs for typed AND dispatched resumes,
1583
1905
  // and only AFTER preparePlanResume succeeds, so a failed prepare
1584
1906
  // never destroys the session context.
1585
- chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
1586
1907
  // Schedule on next tick so the current handleSubmit's promise
1587
1908
  // resolves cleanly before the next one starts. Recursion via
1588
1909
  // setImmediate keeps the call stack flat across long chains.
1589
- setImmediate(() => { void handleSubmit(dispatched); });
1910
+ // (autoDispatch skips it when a post-turn Ctrl+C / /exit asked to stop.)
1911
+ autoDispatch(dispatched);
1590
1912
  }
1591
1913
  };
1592
1914
  // BUG-2b (2026-07-05 live smoke) — single-flight dispatch guard.
@@ -1605,47 +1927,143 @@ export async function runRepl(opts) {
1605
1927
  // check here is a backstop for any direct caller that bypasses
1606
1928
  // beginSubmit's guard — it queues the line as steering, exactly like
1607
1929
  // beginSubmit does mid-turn, instead of starting a twin.
1930
+ /** Round 4 item 9b — the in-flight (or last) dispatch; settled before unmount on exit. */
1931
+ let dispatchTail = null;
1608
1932
  const handleSubmit = async (line) => {
1609
1933
  if (turnStatusEmitter.isBusy()) {
1610
1934
  const t = line.trim();
1935
+ // Fix round 3 (item 5): slash commands are never steering text.
1936
+ const kind = classifyMidTurnLine(t);
1937
+ if (kind.kind === 'exit') {
1938
+ onExitRequest();
1939
+ return;
1940
+ }
1941
+ if (kind.kind === 'slash') {
1942
+ chunkEmitter.emit('chunk', midTurnSlashHint(kind.command));
1943
+ return;
1944
+ }
1611
1945
  if (t.length > 0) {
1612
1946
  enqueueSteering(t);
1613
- const echo = t.split('\n')[0].slice(0, 60);
1614
- chunkEmitter.emit('chunk', chalk.cyan(`↳ queued ▸ ${echo}${t.length > echo.length ? '…' : ''}\n`));
1947
+ chunkEmitter.emit('chunk', steeringEchoLine(t));
1615
1948
  }
1616
1949
  return;
1617
1950
  }
1618
1951
  turnStatusEmitter.reserve();
1952
+ postTurn = false;
1953
+ postTurnMayChain = false;
1619
1954
  try {
1620
- await dispatchSubmit(line);
1955
+ // Round 4 item 9b — the exit path waits (capped) for this to finish.
1956
+ const run = dispatchSubmit(line);
1957
+ dispatchTail = run;
1958
+ await run;
1621
1959
  }
1622
1960
  finally {
1623
1961
  turnStatusEmitter.release();
1962
+ postTurn = false;
1963
+ postTurnMayChain = false;
1964
+ cancelAnnouncedPhase = null;
1965
+ // A cancel asked for during prep of a dispatch that never started a
1966
+ // turn must not leak into the next one.
1967
+ pendingCancel.clear();
1968
+ interruptCount = 0;
1969
+ // `/exit` typed mid-turn: the turn has now stopped and saved — exit.
1970
+ if (exitAfterTurn) {
1971
+ done = true;
1972
+ if (resolveDone)
1973
+ resolveDone();
1974
+ }
1975
+ }
1976
+ };
1977
+ // Fix round 3 (item 5) — `/exit` / `/quit` typed while a turn runs:
1978
+ // cancel the turn the same way Ctrl+C does, then exit once dispatch
1979
+ // returns (handleSubmit's finally). Mid-prep (no abort controller yet)
1980
+ // the cancel is held and applied when the turn creates its controller,
1981
+ // or stops routing outright (review I2).
1982
+ let exitAfterTurn = false;
1983
+ // Final review B-I1 — a /rewind restore is not a turn and runs outside
1984
+ // handleSubmit: interrupts during it are decided as idle, /exit waits for
1985
+ // it, and the cancel/exit state is reset when it ends.
1986
+ const restoreGuard = createRestoreGuard({
1987
+ resetDispatchState: () => {
1988
+ cancelAnnouncedPhase = null;
1989
+ pendingCancel.clear();
1990
+ interruptCount = 0;
1991
+ },
1992
+ exit: () => { done = true; if (resolveDone)
1993
+ resolveDone(); },
1994
+ });
1995
+ const onExitRequest = () => {
1996
+ if (restoreGuard.active) {
1997
+ restoreGuard.requestExit();
1998
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting when the restore has finished'));
1999
+ return;
2000
+ }
2001
+ if (!turnStatusEmitter.isBusy() && !currentTurnAbort) {
2002
+ done = true;
2003
+ if (resolveDone)
2004
+ resolveDone();
2005
+ return;
2006
+ }
2007
+ exitAfterTurn = true;
2008
+ if (currentTurnAbort && interruptCount === 0) {
2009
+ interruptCount++;
2010
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · exiting when it has saved'));
2011
+ currentTurnAbort.abort();
2012
+ }
2013
+ else if (!currentTurnAbort) {
2014
+ announcePendingCancel(true);
1624
2015
  }
1625
2016
  };
1626
2017
  // Phase D5 (2026-05-17) — Ctrl+C handler routed through Ink.
1627
2018
  // First press during turn: abort the turn (preserves partial work).
1628
2019
  // Second press during the SAME turn: hard-exit (escape hatch in case
1629
2020
  // the abort doesn't take effect for some reason).
1630
- // Press at idle: clean exit (printGoodbye + leaveAltScreen).
2021
+ // Press at idle (fix round 3, item 4): the first press only arms the
2022
+ // exit — App shows "Press Ctrl+C again to exit" — and a second press
2023
+ // within IDLE_EXIT_WINDOW_MS exits cleanly (printGoodbye + leaveAltScreen).
2024
+ // Cards / sheets / palettes eat their own Ctrl+C and never get here.
2025
+ let idleInterruptAt = 0;
1631
2026
  const onInterrupt = () => {
1632
- if (currentTurnAbort) {
2027
+ const now = Date.now();
2028
+ const action = decideInterrupt({
2029
+ turnActive: currentTurnAbort !== null,
2030
+ // Review I3 — routing / prep in flight without a controller yet is
2031
+ // not idle: cancel the pending turn, never arm "press again".
2032
+ busy: turnStatusEmitter.isBusy() && !restoreGuard.active,
2033
+ phase: cancelPhase(),
2034
+ canChain: canChainNow(),
2035
+ turnPresses: interruptCount,
2036
+ lastIdleAt: idleInterruptAt,
2037
+ now,
2038
+ });
2039
+ if (action === 'abort-turn') {
1633
2040
  interruptCount++;
1634
- if (interruptCount === 1) {
1635
- chunkEmitter.emit('chunk', chalk.yellow('\n⚠ Interrupting turn — saving partial work...\n'));
1636
- currentTurnAbort.abort();
1637
- }
1638
- else {
1639
- // Second Ctrl+C during the same turn — give up and exit.
1640
- chunkEmitter.emit('chunk', chalk.red('\n✗ Force-exit on second Ctrl+C.\n'));
1641
- unmount();
1642
- process.stdout.write('\x1b[?25h'); // restore cursor
1643
- printGoodbye(session);
1644
- process.exit(130); // 128 + SIGINT
1645
- }
2041
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Stopping the turn · saving the work done so far'));
2042
+ currentTurnAbort.abort();
2043
+ }
2044
+ else if (action === 'cancel-pending') {
2045
+ interruptCount++;
2046
+ announcePendingCancel(false);
2047
+ }
2048
+ else if (action === 'force-exit') {
2049
+ // Second Ctrl+C during the same turn — give up and exit.
2050
+ chunkEmitter.emit('chunk', '\n' + formatInfo('Exiting · second Ctrl+C'));
2051
+ unmount();
2052
+ process.stdout.write('\x1b[?25h'); // restore cursor
2053
+ printGoodbye(session);
2054
+ // Final review B M-2 — through gracefulExit (fetch drain, capped).
2055
+ // Re-review item 3 — mark the session done first, so nothing queued
2056
+ // (auto-dispatch, the loop) starts during the drain window.
2057
+ done = true;
2058
+ exitAfterTurn = true;
2059
+ void gracefulExit(130); // 128 + SIGINT
2060
+ }
2061
+ else if (action === 'arm') {
2062
+ idleInterruptAt = now;
2063
+ return 'armed';
1646
2064
  }
1647
2065
  else {
1648
- // Idle — clean exit, same path as /exit.
2066
+ // Second idle press inside the window — clean exit, same path as /exit.
1649
2067
  done = true;
1650
2068
  if (resolveDone)
1651
2069
  resolveDone();
@@ -1665,7 +2083,10 @@ export async function runRepl(opts) {
1665
2083
  enableBracketedPaste();
1666
2084
  process.on('exit', () => disableBracketedPaste());
1667
2085
  const inkStdin = new InkStdinPasteGuard();
1668
- process.stdin.pipe(inkStdin);
2086
+ // Live fix 2026-09-26 — an @inquirer prompt that ran before this point
2087
+ // (resume picker, SAP picker, API key, first run, onboarding) leaves
2088
+ // Windows console stdin dead to the pipe. Cycle raw mode + flow first.
2089
+ pipeRevivedStdin(inkStdin, process.stdin);
1669
2090
  // Task 3 (ux-wave3) — rewind wiring, shared with the classic path through
1670
2091
  // executeRestore (which holds the single-flight reserve for the FULL
1671
2092
  // restore — review I1a: a restore counts as busy, submits become steering —
@@ -1676,25 +2097,25 @@ export async function runRepl(opts) {
1676
2097
  const inkRequestApproval = (change, risk, transport) => new Promise((resolve) => {
1677
2098
  approvalEmitter.emit('request', { change, effectiveRisk: risk, transport, resolve });
1678
2099
  });
1679
- const runInkRestore = (c) => executeRestore(c, {
2100
+ const runInkRestore = (c) => restoreGuard.run(() => executeRestore(c, {
1680
2101
  adt, cfg, alias: selectedAlias,
1681
- toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2102
+ toolCtx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: inkPreview, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1682
2103
  suppliedTransport: currentTransportAccessor.get() ?? undefined,
1683
2104
  requestApproval: inkRequestApproval,
1684
2105
  // deps.log streams each pipeline step dim to scrollback live; the final
1685
- // ✓ / ↩ cancelled / ✗ summary comes from executeRestore's print.
2106
+ // the done / cancelled / failed summary comes from executeRestore's print.
1686
2107
  log: (line) => chunkEmitter.emit('chunk', chalk.dim(line) + '\n'),
1687
- }, (line) => chunkEmitter.emit('chunk', line + '\n'));
2108
+ }, (line) => chunkEmitter.emit('chunk', line + '\n')));
1688
2109
  const { unmount } = render(React.createElement(App, {
1689
2110
  chunkEmitter,
1690
2111
  onSubmit: (line) => { void handleSubmit(line); },
1691
2112
  onSlashTrigger: () => { },
1692
2113
  onHistoryTrigger: () => { },
1693
2114
  getToolCalls: () => session.toolCalls,
1694
- // Task 5 (ux-wave2) — todo-panel seed accessor (getToolCalls
1695
- // pattern): App reads persisted todos on mount and on every
1696
- // Ctrl+T open, so a resumed session shows its list before any
1697
- // todo_set fires in this process.
2115
+ // Live-checklist seed accessor (getToolCalls pattern; piece 2 P2):
2116
+ // App reads persisted todos on mount and when the rewind panel
2117
+ // closes, so a resumed session shows its plan before any todo_set
2118
+ // fires in this process.
1698
2119
  getTodos: () => session.todos ?? [],
1699
2120
  // Task 3 (ux-wave3) — rewind panel accessors. Candidates join
1700
2121
  // session.toolCalls with the on-disk snapshot list (async fs read),
@@ -1711,6 +2132,7 @@ export async function runRepl(opts) {
1711
2132
  sapRole: cfg.sap[selectedAlias]?.role,
1712
2133
  sapAlias: selectedAlias,
1713
2134
  onInterrupt,
2135
+ onExitRequest,
1714
2136
  }), {
1715
2137
  // Bug 2 (wave1 live-fix) — Ink reads from the paste guard, not
1716
2138
  // process.stdin directly. The guard satisfies Ink's full stdin
@@ -1732,7 +2154,12 @@ export async function runRepl(opts) {
1732
2154
  patchConsole: true,
1733
2155
  });
1734
2156
  await donePromise;
2157
+ // Round 4 item 9b — a second idle Ctrl+C right after a turn resolved the
2158
+ // exit while post-turn work (saving, audit) still ran. Let it finish,
2159
+ // capped so a stuck step can never hold the exit.
2160
+ const exitTail = await settleWithin(dispatchTail, EXIT_TAIL_CAP_MS);
1735
2161
  unmount();
2162
+ setTraceEmitter(undefined);
1736
2163
  // Bug 2 (wave1 live-fix) — undo the paste plumbing so the user's
1737
2164
  // shell doesn't keep receiving marker-wrapped pastes after exit.
1738
2165
  process.stdin.unpipe(inkStdin);
@@ -1744,6 +2171,11 @@ export async function runRepl(opts) {
1744
2171
  // line lands in the user's shell scrollback alongside the session
1745
2172
  // history. In inline mode the conversation IS in scrollback (no
1746
2173
  // alt-screen erased it), so this is just a friendly bookend.
2174
+ // Review M7 — the cap expired: the process exits next, so say the save may
2175
+ // not have landed instead of cutting it off silently.
2176
+ if (exitTail === 'timeout') {
2177
+ console.log(formatAttention({ text: 'Post-turn work did not finish', fact: 'the session file may be incomplete' }).trimEnd());
2178
+ }
1747
2179
  printGoodbye(session);
1748
2180
  return 0;
1749
2181
  }
@@ -1756,7 +2188,10 @@ export async function runRepl(opts) {
1756
2188
  // a single line before readline sees it. See src/repl/bracketed-paste.ts.
1757
2189
  enableBracketedPaste();
1758
2190
  const pasteInput = new BracketedPasteDecoder();
1759
- process.stdin.pipe(pasteInput);
2191
+ // Review I2: the classic path is reached through the same pre-REPL inquirer
2192
+ // prompts (resume picker, SAP picker, password, first run) — revive stdin
2193
+ // with the full reset cycle before piping, exactly like the Ink branch.
2194
+ pipeRevivedStdin(pasteInput, process.stdin);
1760
2195
  const history = loadHistory();
1761
2196
  const rl = readline.createInterface({
1762
2197
  input: pasteInput,
@@ -1790,7 +2225,7 @@ export async function runRepl(opts) {
1790
2225
  // empty. Attached ONLY for non-TTY stdin: interactive type-ahead during
1791
2226
  // a turn keeps its existing semantics.
1792
2227
  const pipedStdin = !process.stdin.isTTY;
1793
- const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl) : noopEarlyLineBuffer();
2228
+ const earlyLines = pipedStdin ? attachEarlyLineBuffer(rl, process.stdin) : noopEarlyLineBuffer();
1794
2229
  // B2 fix (2026-06-12) — classic-mode notice channel. Tool handlers emit
1795
2230
  // 'warn'/'info' (and pre-formatted 'chunk' lines like the write verify
1796
2231
  // chain) on ctx.chunkEmitter; in classic mode that slot used to be
@@ -1800,8 +2235,9 @@ export async function runRepl(opts) {
1800
2235
  // keep their direct-stdout classic path (spinner stays enabled).
1801
2236
  const classicOutputEmitter = createClassicOutputEmitter();
1802
2237
  // Prompt string: 'CSPeach ' + '›' in peach colour
1803
- const promptStr = 'CSPeach ' + chalk.hex(PEACH)('›') + ' ';
1804
- // When we dispatch an inquirer sub-prompt (coaching picker, pickSkill),
2238
+ // I1 — the classic prompt is not legacy-pinned; it takes the brand role (spec §1.1).
2239
+ const promptStr = 'CSPeach ' + paint('brand', '›') + ' ';
2240
+ // When we dispatch an inquirer sub-prompt (e.g. the coaching picker),
1805
2241
  // readline is paused to give inquirer exclusive stdin access. On resolve
1806
2242
  // inquirer sometimes disturbs the stdin stream in a way that makes our
1807
2243
  // readline fire 'close' even though the user did NOT press Ctrl-D. Suppress
@@ -1835,7 +2271,7 @@ export async function runRepl(opts) {
1835
2271
  // v0.6 — module-scope flag the per-turn SIGINT handler sets to true while
1836
2272
  // a turn is in flight. Both `rl.on('close')` and `rl.on('SIGINT')` below
1837
2273
  // check this flag and bail out so they don't race against the turn-scoped
1838
- // handler that wants to abort the LLM stream and render "✋ Turn cancelled."
2274
+ // handler that wants to abort the LLM stream and render "· Turn cancelled"
1839
2275
  // Without this flag, Ctrl+C during a stream lands in the readline SIGINT
1840
2276
  // path first (line buffer is empty during streaming → "Goodbye" + exit),
1841
2277
  // killing the CLI before the turn handler can do anything useful.
@@ -1875,7 +2311,7 @@ export async function runRepl(opts) {
1875
2311
  // v0.6 — turn-scoped SIGINT handler (registered just before runTurn
1876
2312
  // and torn down right after) takes precedence. If a turn is in flight,
1877
2313
  // the user wants to interrupt THE TURN, not exit the CLI. Returning
1878
- // early lets the turn handler run its abort + "✋ Turn cancelled." UX.
2314
+ // early lets the turn handler run its abort + "· Turn cancelled" UX.
1879
2315
  if (turnInProgress)
1880
2316
  return;
1881
2317
  // Inspect the readline's internal line buffer + our prefill state.
@@ -2001,9 +2437,11 @@ export async function runRepl(opts) {
2001
2437
  // can pause us and suppress the spurious 'close' inquirer emits on
2002
2438
  // release. See src/repl/inquirer-guard.ts for the failure mode this
2003
2439
  // prevents (CSPeach silently exiting after a tool-mediated prompt).
2440
+ // Fix 8 (2026-09-26): pass pasteInput so a prompt detaches it from stdin —
2441
+ // keys typed into the prompt must not be replayed into the REPL line.
2004
2442
  registerReadline(rl, (v) => {
2005
2443
  suppressRlClose = v;
2006
- });
2444
+ }, { input: pasteInput });
2007
2445
  // 2026-05-08: one-time workspace-default migration. The default workspace
2008
2446
  // changed from `~/CSPeach/` to project-rooted `<git-root>/.cspeach/`.
2009
2447
  // Existing users with files in ~/CSPeach/ get asked once how to handle
@@ -2058,7 +2496,7 @@ export async function runRepl(opts) {
2058
2496
  // transcript reset — it runs for typed AND dispatched resumes, and
2059
2497
  // only AFTER preparePlanResume succeeds, so a failed prepare never
2060
2498
  // destroys the session context.
2061
- console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
2499
+ console.log(formatInfo(`Auto-routed: ${dispatchedCmd}`).trimEnd());
2062
2500
  line = dispatchedCmd;
2063
2501
  }
2064
2502
  else if (earlyLines.size() > 0) {
@@ -2114,6 +2552,9 @@ export async function runRepl(opts) {
2114
2552
  const trimmed = line.trim();
2115
2553
  if (!trimmed)
2116
2554
  continue;
2555
+ // Round 6 review I2 — see the Ink path.
2556
+ const resumedSkill = resumeKeepsSkill ? session.skill ?? null : null;
2557
+ resumeKeepsSkill = false;
2117
2558
  // Persist non-empty input to history file (Task 8: consecutive
2118
2559
  // duplicates are skipped inside the shared writer).
2119
2560
  appendHistoryEntry(trimmed);
@@ -2147,7 +2588,7 @@ export async function runRepl(opts) {
2147
2588
  continue;
2148
2589
  }
2149
2590
  if (trimmed.endsWith(' /cancel')) {
2150
- console.log(chalk.dim('↩ cancelled'));
2591
+ console.log(formatRoutingLine({ kind: 'cancelled' }).trimEnd());
2151
2592
  continue;
2152
2593
  }
2153
2594
  if (trimmed === '/exit' || trimmed === '/quit') {
@@ -2215,7 +2656,7 @@ export async function runRepl(opts) {
2215
2656
  if (trimmed === '/mode' || trimmed.startsWith('/mode ')) {
2216
2657
  const modeArg = trimmed === '/mode' ? undefined : (trimmed.slice('/mode '.length).trim() || undefined);
2217
2658
  const { line: modeLine, mode } = runModeCommand(modeArg, cfg.write_mode, cfg.sap[selectedAlias]?.role, selectedAlias);
2218
- console.log(chalk.dim(modeLine));
2659
+ console.log(formatInfo(modeLine).trimEnd());
2219
2660
  sapStateStore.update({ writeMode: mode });
2220
2661
  continue;
2221
2662
  }
@@ -2228,13 +2669,9 @@ export async function runRepl(opts) {
2228
2669
  const recapWs = await resolveWorkspacePath().catch(() => undefined);
2229
2670
  const recapLines = buildRecap(session, process.cwd(), { workspaceDir: recapWs });
2230
2671
  console.log('');
2231
- if (recapLines.length === 0) {
2232
- console.log(chalk.dim('nothing to recap yet'));
2233
- }
2234
- else {
2235
- for (const recapLine of recapLines)
2236
- console.log(chalk.dim(recapLine));
2237
- }
2672
+ console.log(recapLines.length === 0
2673
+ ? formatInfo('Nothing to recap yet').trimEnd()
2674
+ : formatPanel('Recap', recapLines.map((l) => ' ' + paint('dim', sanitizeControlChars(l)))).join('\n'));
2238
2675
  continue;
2239
2676
  }
2240
2677
  // Phase 2 conversation hygiene (2026-05-16) — classic-mode /compact.
@@ -2252,12 +2689,12 @@ export async function runRepl(opts) {
2252
2689
  { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
2253
2690
  };
2254
2691
  try {
2255
- console.log(chalk.dim('\nCompacting older turns into a summary...'));
2692
+ console.log('\n' + formatInfo('Compacting older turns into a summary…').trimEnd());
2256
2693
  const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
2257
2694
  console.log(formatCompactResult(result, compactCfg.keep_recent_turns));
2258
2695
  }
2259
2696
  catch (err) {
2260
- console.log(chalk.red(`\n/compact failed: ${err instanceof Error ? err.message : String(err)}\n`));
2697
+ console.log('\n' + formatFailure({ what: 'Compact failed', detail: [err instanceof Error ? err.message : String(err)] }));
2261
2698
  }
2262
2699
  continue;
2263
2700
  }
@@ -2278,7 +2715,7 @@ export async function runRepl(opts) {
2278
2715
  const { openFilePicker } = await import('./repl/file-picker.js');
2279
2716
  const result = await openFilePicker();
2280
2717
  if (result.kind === 'command') {
2281
- console.log(chalk.dim(`↪ Picked. Press Enter to run, or edit first.`));
2718
+ console.log(formatInfo('Picked · press Enter to run, or edit first').trimEnd());
2282
2719
  pendingPrefill = result.command;
2283
2720
  }
2284
2721
  else if (result.kind === 'empty') {
@@ -2293,7 +2730,7 @@ export async function runRepl(opts) {
2293
2730
  // result.kind === 'cancelled' — print nothing.
2294
2731
  }
2295
2732
  catch (err) {
2296
- console.log(chalk.red(`\n[/files] ${err.message}\n`));
2733
+ console.log('\n' + formatFailure({ what: 'Could not open the file list', detail: [err.message] }));
2297
2734
  }
2298
2735
  continue;
2299
2736
  }
@@ -2302,31 +2739,11 @@ export async function runRepl(opts) {
2302
2739
  // Defensive: a config that predates the [ui] section may have cfg.ui === undefined
2303
2740
  // if loaded before loader.ts's deep-merge fix. Initialise to the default.
2304
2741
  if (!cfg.ui)
2305
- cfg.ui = { rendering: 'auto' };
2742
+ cfg.ui = { rendering: 'auto', look: DEFAULT_CONFIG.ui.look };
2306
2743
  const arg = trimmed.slice(3).trim().toLowerCase();
2307
- if (arg === '') {
2308
- console.log(`\nCurrent UI mode: ${chalk.cyan.bold(cfg.ui.rendering)} ` +
2309
- `(effective now: ${chalk.yellow(shouldUseInk() ? 'ink' : 'classic')})\n`);
2310
- console.log(chalk.dim(' /ui classic — chalk REPL (v0.2 style — default, fully tested)'));
2311
- console.log(chalk.dim(' /ui ink — Ink frame (v0.3 — opt-in while polish is pending)'));
2312
- console.log(chalk.dim(' /ui auto — choose automatically based on terminal'));
2313
- console.log(chalk.dim('\nChanges are written to ~/.cspeach/config.toml and'));
2314
- console.log(chalk.dim('take effect the next time you launch cspeach.\n'));
2315
- }
2316
- else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
2317
- cfg.ui.rendering = arg;
2318
- try {
2319
- await saveConfig(cfg);
2320
- console.log(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
2321
- console.log(chalk.dim('Relaunch cspeach (`/exit` then `cspeach`) to apply.\n'));
2322
- }
2323
- catch (err) {
2324
- console.log(chalk.red(`\n✗ Failed to save config: ${err.message}\n`));
2325
- }
2326
- }
2327
- else {
2328
- console.log(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.\n`));
2329
- }
2744
+ if (await handleUiLook(arg, cfg, saveConfig, (s) => console.log(s)))
2745
+ continue;
2746
+ await handleUiRendering(arg, cfg, saveConfig, (s) => console.log(s), shouldUseInk() ? 'ink' : 'classic');
2330
2747
  continue;
2331
2748
  }
2332
2749
  // /reroute — re-dispatch session.lastUserPrompt to a different skill
@@ -2338,15 +2755,25 @@ export async function runRepl(opts) {
2338
2755
  continue;
2339
2756
  }
2340
2757
  const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
2341
- console.log(chalk.dim(`↪ Rerouting "${preview}" → /${rr.skill}`));
2758
+ console.log(formatInfo(`Rerouting "${preview}" to /${rr.skill}`).trimEnd());
2342
2759
  if (rr.warning)
2343
2760
  console.log(chalk.dim(` (${rr.warning})`));
2344
- await runTurn({
2345
- provider,
2346
- userMessage: rr.prompt,
2347
- skill: rr.skill,
2348
- ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2349
- pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2761
+ // A turn error prints the same stderr block as the normal turn path.
2762
+ await runRerouteTurn({
2763
+ run: () => runTurn({
2764
+ provider,
2765
+ userMessage: rr.prompt,
2766
+ skill: rr.skill,
2767
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2768
+ pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2769
+ }),
2770
+ session,
2771
+ print: (line) => console.error(line),
2772
+ onFinally: () => {
2773
+ // P2 fix round 1 — /reroute skips pushPostTurnStatus, so run its
2774
+ // turn-end plan step here (classic prints the block once).
2775
+ emitPlanTurnEnd(session);
2776
+ },
2350
2777
  });
2351
2778
  continue;
2352
2779
  }
@@ -2354,7 +2781,8 @@ export async function runRepl(opts) {
2354
2781
  // classified afresh instead of continuing the previous skill's Q&A chain.
2355
2782
  if (trimmed === '/new' || trimmed === '/reset') {
2356
2783
  session.awaitingSkillAnswer = false;
2357
- console.log(chalk.dim('↩ fresh start — next prompt will be classified normally'));
2784
+ session.todos = []; // P2 — /new clears the plan (spec §4)
2785
+ console.log(formatInfo('Fresh start · the next prompt picks a skill again').trimEnd());
2358
2786
  continue;
2359
2787
  }
2360
2788
  // /transport — session current-transport state for Rule 9 (transport isolation)
@@ -2409,7 +2837,7 @@ export async function runRepl(opts) {
2409
2837
  // the prefilled buffer so iter 3 starts fresh, clear the
2410
2838
  // guard, and skip dispatch.
2411
2839
  lastCancelledSlashLine = null;
2412
- console.log(chalk.dim('↩ dropped'));
2840
+ console.log(formatInfo('Dropped').trimEnd());
2413
2841
  process.nextTick(() => prefillReadline(rl, ''));
2414
2842
  continue;
2415
2843
  }
@@ -2421,7 +2849,7 @@ export async function runRepl(opts) {
2421
2849
  const prefix = head.slice(1); // drop the leading /
2422
2850
  const picked = await openSlashPicker(prefix);
2423
2851
  if (picked === null) {
2424
- console.log(chalk.dim('↩ cancelled — edit, or press Enter again to drop'));
2852
+ console.log(formatInfo('Cancelled · edit, or press Enter again to drop').trimEnd());
2425
2853
  lastCancelledSlashLine = trimmed;
2426
2854
  process.nextTick(() => prefillReadline(rl, trimmed));
2427
2855
  continue;
@@ -2448,13 +2876,13 @@ export async function runRepl(opts) {
2448
2876
  // Without this the line went to the LLM with a bare/partial
2449
2877
  // @ — wasted a turn and confused the user who'd just cancelled.
2450
2878
  lastCancelledAtLine = null;
2451
- console.log(chalk.dim('↩ dropped'));
2879
+ console.log(formatInfo('Dropped').trimEnd());
2452
2880
  process.nextTick(() => prefillReadline(rl, ''));
2453
2881
  continue;
2454
2882
  }
2455
2883
  const pickedFile = await openAtPicker(atPrefix);
2456
2884
  if (pickedFile === null) {
2457
- console.log(chalk.dim('↩ cancelled — edit, or press Enter again to drop'));
2885
+ console.log(formatInfo('Cancelled · edit, or press Enter again to drop').trimEnd());
2458
2886
  lastCancelledAtLine = resolvedTrimmed;
2459
2887
  process.nextTick(() => prefillReadline(rl, resolvedTrimmed));
2460
2888
  continue;
@@ -2499,9 +2927,7 @@ export async function runRepl(opts) {
2499
2927
  // then asked an unanswerable question. Every skill now carries an
2500
2928
  // `asks` line with an example (skill-catalog.ts).
2501
2929
  const asks = SKILL_CATALOG.find((e) => `/${e.name}` === head)?.asks;
2502
- console.log(chalk.dim(asks
2503
- ? `↪ ${head} — ${asks}`
2504
- : `${'↪'} ${head} — type your prompt and press Enter`));
2930
+ console.log(formatInfo(asks ? `${head} — ${asks}` : `${head} — type your prompt and press Enter`).trimEnd());
2505
2931
  console.log(chalk.dim(' Enter to send · /cancel to abandon'));
2506
2932
  lastEmptyPrefill = head;
2507
2933
  }
@@ -2567,15 +2993,16 @@ export async function runRepl(opts) {
2567
2993
  chalk.dim('Type a new question, /new to reset, or /exit to quit.'));
2568
2994
  continue;
2569
2995
  }
2570
- if ((session.awaitingSkillAnswer || isAckContinuation(trimmed, session.skill)) && session.skill) {
2571
- console.log(chalk.dim(`↪ Continuing /${session.skill}`));
2996
+ // Round 6 item 4c / review I2 — see the Ink path.
2997
+ if ((session.awaitingSkillAnswer || (resumedSkill !== null && isShortFollowUp(trimmed)) || isAckContinuation(trimmed, session.skill)) && session.skill) {
2998
+ console.log(formatRoutingLine({ kind: 'continuing', skill: session.skill }).trimEnd());
2572
2999
  skill = session.skill;
2573
3000
  }
2574
3001
  else if (isBareAck(trimmed)) {
2575
3002
  // Bare "y" / "n" / "cancel" with no skill to continue — almost always
2576
3003
  // a stray keystroke after an aborted turn, not a request to start
2577
3004
  // work. Don't burn a classifier round-trip on it.
2578
- console.log(chalk.dim('↩ no active skill — type /help or a full prompt'));
3005
+ console.log(formatInfo('No active skill · type /help or a full prompt').trimEnd());
2579
3006
  continue;
2580
3007
  }
2581
3008
  else {
@@ -2585,46 +3012,76 @@ export async function runRepl(opts) {
2585
3012
  last_skill: session.last_skill ?? null,
2586
3013
  last_object: extractedObject ?? session.last_object ?? null,
2587
3014
  });
2588
- if (c.offline) {
2589
- console.log(chalk.dim(`↪ [offline classifier — proxy unreachable: ${c.transport_error ?? 'unknown'}]`));
2590
- }
2591
3015
  if (c.screenshot)
2592
- console.log(chalk.dim(`┄ Screenshot: ${c.screenshot}`));
2593
- const decision = decideRouting(c);
2594
- // 2026-05-17 — pass the just-used skill so the picker can
2595
- // default cursor to it. session.skill = immediately-previous
2596
- // turn's skill, NOT session.last_skill (TWO turns ago).
2597
- const continueSkillClassic = session.skill ?? null;
2598
- let pickedResult;
2599
- // Coaching picker path — inquirer takes over stdin. Use the shared
2600
- // withInquirer guard so the suppress-close window stays active
2601
- // across the close-fire tick (inline guard cleared the flag
2602
- // synchronously, leaking the close-fire and dropping CSPeach to
2603
- // the shell mid-session).
2604
- const runPicker = async (params) => {
2605
- return withInquirer(() => coachingPickerClassic(params));
2606
- };
2607
- if (cfg.classifier.safe_mode) {
2608
- // Safe mode overrides the decision — always open the full picker.
2609
- pickedResult = await runPicker(buildCoachingParams(c, trimmed, false, continueSkillClassic));
2610
- }
2611
- else if (decision.kind === 'silent') {
2612
- console.log(chalk.dim(`┄ Routed to /${decision.skill}`));
2613
- pickedResult = decision.skill;
3016
+ console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
3017
+ // Fix 10 (2026-09-26): routing failed mid-skill → stay in the
3018
+ // active skill (one dim line), never the picker. Same decision as
3019
+ // the Ink path (routingFailureOutcome).
3020
+ const failed = routingFailureOutcome(routingFailureFromResult(c), session.skill);
3021
+ if (failed.kind === 'stay') {
3022
+ console.log(formatInfo(failed.line).trimEnd());
3023
+ skill = failed.skill;
2614
3024
  }
2615
3025
  else {
2616
- const preSelect = decision.kind === 'coach';
2617
- pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect, continueSkillClassic));
3026
+ if (c.offline) {
3027
+ // Fix 9: true wording from the failure kind (a timeout is not "offline").
3028
+ console.log(formatAttention(routingFailureNotice(routingFailureFromResult(c), c.transport_error)).trimEnd());
3029
+ }
3030
+ // Round 6 review I2 — see the Ink path.
3031
+ const decision = resumedSkill ? applyResumedSkill(decideRouting(c), resumedSkill) : decideRouting(c);
3032
+ // 2026-05-17 — pass the just-used skill so the picker can
3033
+ // default cursor to it. session.skill = immediately-previous
3034
+ // turn's skill, NOT session.last_skill (TWO turns ago).
3035
+ const continueSkillClassic = session.skill ?? null;
3036
+ let pickedResult;
3037
+ // Coaching picker path — inquirer takes over stdin. Use the shared
3038
+ // withInquirer guard so the suppress-close window stays active
3039
+ // across the close-fire tick (inline guard cleared the flag
3040
+ // synchronously, leaking the close-fire and dropping CSPeach to
3041
+ // the shell mid-session).
3042
+ const runPicker = async (params) => {
3043
+ return withInquirer(() => coachingPickerClassic(params));
3044
+ };
3045
+ if (pipedStdin) {
3046
+ // Round 7 item 8a — piped stdin has no keyboard to answer the
3047
+ // picker (it ended in "readline was closed"): run the router's
3048
+ // top pick, like one-shot.
3049
+ const p = pipedRoutingPick(c);
3050
+ for (const l of p.lines)
3051
+ console.log(l);
3052
+ pickedResult = p.skill;
3053
+ }
3054
+ else if (cfg.classifier.safe_mode) {
3055
+ // Safe mode overrides the decision — always open the full picker.
3056
+ pickedResult = await runPicker(buildCoachingParams(c, trimmed, false, continueSkillClassic));
3057
+ }
3058
+ else if (decision.kind === 'silent') {
3059
+ console.log(formatRoutingLine({ kind: 'using', skill: decision.skill }).trimEnd());
3060
+ pickedResult = decision.skill;
3061
+ }
3062
+ else {
3063
+ const preSelect = decision.kind === 'coach';
3064
+ pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect, continueSkillClassic));
3065
+ }
3066
+ if (pickedResult === PICK_CANCELLED) {
3067
+ console.log(formatRoutingLine({ kind: 'cancelled' }).trimEnd());
3068
+ continue;
3069
+ }
3070
+ skill = pickedResult;
3071
+ } // end fix-10 fallback branch
3072
+ }
3073
+ catch (err) {
3074
+ const failure = routingFailureFromError(err);
3075
+ const failed = routingFailureOutcome(failure, session.skill);
3076
+ if (failed.kind === 'stay') {
3077
+ // Fix 10: HTTP routing failure mid-skill → stay in the active skill.
3078
+ console.log(formatInfo(failed.line).trimEnd());
3079
+ skill = failed.skill;
2618
3080
  }
2619
- if (pickedResult === PICK_CANCELLED) {
2620
- console.log(chalk.dim('↩ cancelled — type a new prompt'));
3081
+ else {
3082
+ console.log('\n' + formatFailure({ what: failure ? routingFailureText(failure) : 'Could not pick a skill for this prompt', detail: [err.message], next: 'start the prompt with a skill, e.g. /abap-explain' }).trimEnd());
2621
3083
  continue;
2622
3084
  }
2623
- skill = pickedResult;
2624
- }
2625
- catch (err) {
2626
- console.log(chalk.red(`\n[classifier error] ${err.message}`));
2627
- continue;
2628
3085
  }
2629
3086
  }
2630
3087
  }
@@ -2652,7 +3109,7 @@ export async function runRepl(opts) {
2652
3109
  });
2653
3110
  }
2654
3111
  catch (err) {
2655
- console.log(chalk.red(`\n[--status] ${err.message}\n`));
3112
+ console.log('\n' + formatFailure({ what: 'Could not show the status', detail: [err.message] }));
2656
3113
  }
2657
3114
  session.last_skill = previousSkill ?? null;
2658
3115
  session.last_object = extractedObject ?? previousObject ?? null;
@@ -2677,7 +3134,7 @@ export async function runRepl(opts) {
2677
3134
  if (skill === 'abap-plan' && /(^|\s)--resume(\s|$)/.test(prompt)) {
2678
3135
  try {
2679
3136
  const { preparePlanResume } = await import('./commands/plan-resume.js');
2680
- const { inkAwarePrompt } = await import('./agent/loop.js');
3137
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
2681
3138
  planResume = await preparePlanResume({
2682
3139
  body: prompt,
2683
3140
  cwd: process.cwd(),
@@ -2688,11 +3145,12 @@ export async function runRepl(opts) {
2688
3145
  // Task 9 — run-lease identity + takeover prompt (classic mirror).
2689
3146
  sessionId: session.id,
2690
3147
  prompt: inkAwarePrompt,
3148
+ confirm: inkAwareConfirm,
2691
3149
  log: (...lines) => lines.forEach((l) => console.log(l)),
2692
3150
  });
2693
3151
  }
2694
3152
  catch (err) {
2695
- console.log(chalk.red(`\n[--resume] ${err.message}\n`));
3153
+ console.log('\n' + formatFailure({ what: 'Could not resume the plan', detail: [err.message] }));
2696
3154
  }
2697
3155
  if (!planResume) {
2698
3156
  session.last_skill = previousSkill ?? null;
@@ -2706,7 +3164,7 @@ export async function runRepl(opts) {
2706
3164
  if (planResume.reauditPhaseId) {
2707
3165
  try {
2708
3166
  const { runReauditPass } = await import('./commands/plan-chain.js');
2709
- const { inkAwarePrompt } = await import('./agent/loop.js');
3167
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
2710
3168
  await runReauditPass({
2711
3169
  savedPath: planResume.path,
2712
3170
  phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
@@ -2718,12 +3176,13 @@ export async function runRepl(opts) {
2718
3176
  auditMode: planResume.auditMode,
2719
3177
  auditFlag: planResume.auditFlag,
2720
3178
  prompt: inkAwarePrompt,
3179
+ confirm: inkAwareConfirm,
2721
3180
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
2722
3181
  log: (...lines) => lines.forEach((l) => console.log(l)),
2723
3182
  });
2724
3183
  }
2725
3184
  catch (err) {
2726
- console.log(chalk.red(`\n[plan] re-audit failed: ${err.message}\n`));
3185
+ console.log('\n' + formatFailure({ what: 'Plan re-audit failed', detail: [err.message] }));
2727
3186
  }
2728
3187
  // Task 9 (review fix) — release ONLY when the re-audit leg queued no
2729
3188
  // follow-up (classic mirror; the loop-top pre-prompt consumer picks
@@ -2742,7 +3201,7 @@ export async function runRepl(opts) {
2742
3201
  {
2743
3202
  const { resetContextForPlanResume } = await import('./commands/plan-resume.js');
2744
3203
  if (resetContextForPlanResume(session)) {
2745
- console.log(chalk.dim('↪ Plan phase starts in a fresh bounded context — prior transcript cleared'));
3204
+ console.log(formatInfo('Plan phase starts in a fresh bounded context · earlier conversation cleared').trimEnd());
2746
3205
  }
2747
3206
  }
2748
3207
  planResumeMsgStart = session.messages.length;
@@ -2788,7 +3247,7 @@ export async function runRepl(opts) {
2788
3247
  planContinue = { prepared: r.prepared, marker };
2789
3248
  planPhaseStartedAt = marker.attemptStartedAt;
2790
3249
  planResumeMsgStart = session.messages.length;
2791
- console.log(chalk.dim(`↪ re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`));
3250
+ console.log(formatInfo(`Re-attaching to in-flight plan phase ${marker.phaseId} (${r.prepared.path})`).trimEnd());
2792
3251
  }
2793
3252
  else if (r.stale) {
2794
3253
  session.plan_in_flight = null; // plan/phase gone for good — marker is noise
@@ -2843,7 +3302,7 @@ export async function runRepl(opts) {
2843
3302
  return;
2844
3303
  turnSigintFired = true;
2845
3304
  interruptedByUser = true;
2846
- process.stderr.write(chalk.yellow('\n⚠ Interrupting turn — saving partial work...\n'));
3305
+ process.stderr.write('\n' + formatInfo('Stopping the turn · saving the work done so far'));
2847
3306
  turnAbort.abort();
2848
3307
  // Remove ourselves so a SECOND Ctrl+C falls through to the
2849
3308
  // existing rl SIGINT handler (which clears the line / exits) once
@@ -2871,7 +3330,7 @@ export async function runRepl(opts) {
2871
3330
  if ((planPhaseCtx.effectiveMode ?? 'step') === 'guarded') {
2872
3331
  setGuardedRunActive(true);
2873
3332
  guardedRunFlagSet = true;
2874
- console.log(chalk.dim('▶ guarded mode — auto_approve disabled for this run'));
3333
+ console.log(formatInfo('Guarded mode · auto-approve is off for this run').trimEnd());
2875
3334
  }
2876
3335
  // Off-switch (2026-07-06, owner) — announce plainly that this run's
2877
3336
  // phases are NOT audited (honesty: off is a choice, never a lie).
@@ -2906,7 +3365,7 @@ export async function runRepl(opts) {
2906
3365
  planSavedEarly = true;
2907
3366
  }
2908
3367
  catch (err) {
2909
- console.log(chalk.red(`\n[plan] early revision save failed: ${err.message}\n`));
3368
+ console.log('\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
2910
3369
  }
2911
3370
  }
2912
3371
  : planContinueForHook
@@ -2933,7 +3392,7 @@ export async function runRepl(opts) {
2933
3392
  // owns the foreign save / quiet-none handling.
2934
3393
  }
2935
3394
  catch (err) {
2936
- console.log(chalk.red(`\n[plan] early continuation save failed: ${err.message}\n`));
3395
+ console.log('\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
2937
3396
  }
2938
3397
  }
2939
3398
  : undefined,
@@ -2990,7 +3449,7 @@ export async function runRepl(opts) {
2990
3449
  });
2991
3450
  }
2992
3451
  catch (err) {
2993
- console.log(chalk.red(`\n[plan] revision save failed: ${err.message}\n`));
3452
+ console.log('\n' + formatFailure({ what: 'Plan revision not saved', detail: [err.message] }));
2994
3453
  }
2995
3454
  }
2996
3455
  // A1 (defect D23) + Task 6 — harness-owned continuation, now the full
@@ -3001,7 +3460,7 @@ export async function runRepl(opts) {
3001
3460
  if (planResume && planOutcome) {
3002
3461
  try {
3003
3462
  const { runPostPhaseChain } = await import('./commands/plan-chain.js');
3004
- const { inkAwarePrompt } = await import('./agent/loop.js');
3463
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
3005
3464
  await runPostPhaseChain({
3006
3465
  savedPath: planOutcome.savedPath,
3007
3466
  executedPhaseId: planResume.nextPhaseId,
@@ -3018,12 +3477,13 @@ export async function runRepl(opts) {
3018
3477
  // Fix-wave (item 1) — counter consumed in the runTurn finally.
3019
3478
  subagentDispatches: planSubagentDispatches,
3020
3479
  prompt: inkAwarePrompt,
3480
+ confirm: inkAwareConfirm,
3021
3481
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
3022
3482
  log: (...lines) => lines.forEach((l) => console.log(l)),
3023
3483
  });
3024
3484
  }
3025
3485
  catch (err) {
3026
- console.log(chalk.red(`\n[plan] post-phase chain failed: ${err.message}\n`));
3486
+ console.log('\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
3027
3487
  }
3028
3488
  }
3029
3489
  // C1 — continuation post-turn: fallback-persist (hook may not have
@@ -3062,13 +3522,13 @@ export async function runRepl(opts) {
3062
3522
  }
3063
3523
  }
3064
3524
  catch (err) {
3065
- console.log(chalk.red(`\n[plan] continuation save failed: ${err.message}\n`));
3525
+ console.log('\n' + formatFailure({ what: 'Plan continuation not saved', detail: [err.message] }));
3066
3526
  }
3067
3527
  }
3068
3528
  if (planContinue && planOutcome) {
3069
3529
  try {
3070
3530
  const { runContinuationChain } = await import('./commands/plan-continue.js');
3071
- const { inkAwarePrompt } = await import('./agent/loop.js');
3531
+ const { inkAwarePrompt, inkAwareConfirm } = await import('./agent/loop.js');
3072
3532
  await runContinuationChain({
3073
3533
  prepared: planContinue.prepared,
3074
3534
  marker: planContinue.marker,
@@ -3077,12 +3537,13 @@ export async function runRepl(opts) {
3077
3537
  cwd: process.cwd(),
3078
3538
  subagentDispatches: planSubagentDispatches,
3079
3539
  prompt: inkAwarePrompt,
3540
+ confirm: inkAwareConfirm,
3080
3541
  queueDispatch: (cmd) => pendingDispatchAccessor.set(cmd),
3081
3542
  log: (...lines) => lines.forEach((l) => console.log(l)),
3082
3543
  });
3083
3544
  }
3084
3545
  catch (err) {
3085
- console.log(chalk.red(`\n[plan] continuation chain failed: ${err.message}\n`));
3546
+ console.log('\n' + formatFailure({ what: 'Plan follow-up steps failed', detail: [err.message] }));
3086
3547
  }
3087
3548
  }
3088
3549
  // C1 — the phase reached a resolved terminal (revision persisted, chain
@@ -3122,6 +3583,8 @@ export async function runRepl(opts) {
3122
3583
  // A4 — price the turn at the model that actually ran it (classic-path
3123
3584
  // mirror of the Ink call site above).
3124
3585
  turnModel: planModelOverride ?? session.model,
3586
+ // Review M4 — a user cancel with no tokens prints no receipt here too.
3587
+ cancelled: interruptedByUser,
3125
3588
  });
3126
3589
  // Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
3127
3590
  // JUST finished becomes `last_skill` for the next turn's classifier context.