@cspeach/cli 0.6.0

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 (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
package/dist/repl.js ADDED
@@ -0,0 +1,1383 @@
1
+ import chalk from 'chalk';
2
+ // The promises-based `readline.createInterface` accepts the same options
3
+ // as the callback version but its TypeScript typings do not expose
4
+ // `rl.history` (only the callback module's interface does). If a future
5
+ // version needs runtime history manipulation (trim, search), cast to the
6
+ // callback-module's InterfaceConstructor signature or import from
7
+ // 'node:readline' directly for that specific operation.
8
+ import * as readline from 'node:readline/promises';
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import * as os from 'node:os';
12
+ import { select, password as promptPassword } from '@inquirer/prompts';
13
+ import { loadConfig } from './config/loader.js';
14
+ import { getStore } from './auth/api-key.js';
15
+ import { readAuthFile } from './auth/auth-file.js';
16
+ import { fetchMe } from './auth/me.js';
17
+ import { formatLoginBanner } from './ui/login-banner.js';
18
+ import { printStartupBanner, printKeytarFallbackBanner } from './renderer/banners.js';
19
+ import { runAddSapWizard } from './sap/onboarding.js';
20
+ import { getConnection } from './sap/connection-manager.js';
21
+ import { AdtClient } from '@cspeach/sap-client';
22
+ import { makeSessionId, saveSession } from './session/store.js';
23
+ import { newSession } from './session/schema.js';
24
+ import { createProviderForMode } from './agent/providers/factory.js';
25
+ import { runTurn } from './agent/loop.js';
26
+ import { renderTurnError } from './agent/turn-error-ux.js';
27
+ import { classifyPrompt, parseShortcut } from './router/classifier.js';
28
+ import { decideRouting } from './router/routing-decision.js';
29
+ import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
30
+ import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
31
+ import { PICK_CANCELLED } from './ui/skill-picker.js';
32
+ import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
33
+ import { maybePromptWorkspaceMigration } from './projects/migration.js';
34
+ import { slashCompleter } from './repl/slash-completer.js';
35
+ import { openSlashPicker, isExactSlashMatch } from './repl/slash-picker.js';
36
+ import { openAtPicker, pendingAtTokenPrefix } from './repl/at-picker.js';
37
+ import { atCompleter } from './repl/at-completer.js';
38
+ /**
39
+ * Pre-fill the readline buffer with `text`, cursor at end, and trigger
40
+ * a redraw. Bypasses rl.write() — which simulates per-character
41
+ * keystrokes and races with rl.question's prompt-reset, leaving the
42
+ * visible characters painted while the internal buffer stays empty
43
+ * (so the first user keystroke wipes the pre-fill on redraw).
44
+ *
45
+ * Direct buffer manipulation via `line`/`cursor` + `_refreshLine` is
46
+ * not part of the public readline API, but the fields have been
47
+ * stable across Node 16/18/20/22 and are what every interactive-prompt
48
+ * library (inquirer, prompts, ink-text-input) ends up calling. The
49
+ * `_refreshLine?.()` optional-chain is the safety net for any future
50
+ * Node where the method goes away.
51
+ */
52
+ function prefillReadline(rl, text) {
53
+ const internal = rl;
54
+ internal.line = text;
55
+ internal.cursor = text.length;
56
+ internal._refreshLine?.();
57
+ }
58
+ import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
59
+ import { printHelp } from './commands/help.js';
60
+ import { printStatusFooter } from './renderer/status-footer.js';
61
+ import { shouldUseInk, setRenderingOverride } from './renderer/tty.js';
62
+ import { saveConfig } from './config/loader.js';
63
+ import { EventEmitter } from 'node:events';
64
+ import { parseAndValidateReroute } from './commands/reroute.js';
65
+ import { SKILL_CATALOG } from './skill-catalog.js';
66
+ import { replPreviewHook } from './repl/update-method-preview-hook.js';
67
+ import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
68
+ // Side-effect imports so tool registrations happen before listTools() is called.
69
+ import './tools/sap-read.js';
70
+ import './tools/sap-write.js';
71
+ import './tools/transport.js';
72
+ import './tools/approval.js';
73
+ import './tools/snapshot.js';
74
+ import './tools/ask-question.js';
75
+ import './tools/dispatch-skill.js';
76
+ import './tools/filesystem/file-read.js';
77
+ import './tools/filesystem/file-edit.js';
78
+ import './tools/filesystem/file-write.js';
79
+ import './tools/filesystem/glob.js';
80
+ import './tools/filesystem/grep.js';
81
+ import './tools/shell/shell_exec.js';
82
+ import './tools/web/web_fetch.js';
83
+ import './tools/web/web_search.js';
84
+ import './tools/project/project_context_get.js';
85
+ import './tools/project/playbook_get.js';
86
+ import './tools/project/convention_get.js';
87
+ import './tools/subagent/_background-shared.js';
88
+ import './tools/subagent/background_run.js';
89
+ import './tools/subagent/monitor_emit.js';
90
+ import './tools/subagent/schedule_create.js';
91
+ import './tools/subagent/agent_run.js';
92
+ const PEACH = '#FFCBA4';
93
+ const HISTORY_FILE = path.join(os.homedir(), '.cspeach', 'history');
94
+ const HISTORY_SIZE = 500;
95
+ function loadHistory() {
96
+ try {
97
+ const raw = fs.readFileSync(HISTORY_FILE, 'utf-8');
98
+ return raw
99
+ .split('\n')
100
+ .map((l) => l.trim())
101
+ .filter((l) => l.length > 0)
102
+ .slice(-HISTORY_SIZE)
103
+ // readline expects history[0] to be the MOST RECENT entry (arrow-up
104
+ // walks from index 0 backwards in time). The on-disk file is
105
+ // chronologically appended — oldest at top, newest at bottom — so
106
+ // after slicing the last N lines we need to reverse them. Without
107
+ // this, arrow-up showed the user the oldest 500 entries first; new
108
+ // ones (correctly persisted to disk) sat at the far end of the buffer
109
+ // and were unreachable.
110
+ .reverse();
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ }
116
+ function appendHistory(line) {
117
+ try {
118
+ fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
119
+ fs.appendFileSync(HISTORY_FILE, line + '\n', 'utf-8');
120
+ }
121
+ catch {
122
+ // Non-fatal — history persistence failure must never crash the REPL.
123
+ }
124
+ }
125
+ /** Short user inputs that almost always mean "continue what we were doing"
126
+ * rather than "start a new task." When the previous turn left a skill
127
+ * in session.skill, an ack-only follow-up should route there instead of
128
+ * re-classifying as a fresh prompt (which produces nonsense like "yes"
129
+ * → /abap-explain at 35% confidence). */
130
+ const ACK_PHRASES = new Set([
131
+ 'yes', 'y', 'yeah', 'yep', 'yup', 'sure',
132
+ 'ok', 'okay', 'k', 'kk',
133
+ 'proceed', 'continue', 'go', 'go ahead', 'do it', 'do that', 'go on',
134
+ 'no', 'n', 'nope', 'nah',
135
+ 'stop', 'cancel', 'abort', 'never mind', 'nm',
136
+ 'approve', 'approved', 'accept', 'accepted',
137
+ 'decline', 'denied', 'reject', 'rejected',
138
+ 'skip', 'next',
139
+ ]);
140
+ /**
141
+ * Returns true when `trimmed` is a short acknowledgement-style follow-up
142
+ * AND there is a previous skill to continue. Treats both bare acks and
143
+ * short ack-prefixed phrases ("yes please", "no thanks") the same way.
144
+ * Strips trailing punctuation so "yes." and "yes!" still register.
145
+ */
146
+ function isAckContinuation(trimmed, sessionSkill) {
147
+ if (!sessionSkill)
148
+ return false;
149
+ const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
150
+ if (ACK_PHRASES.has(normalized))
151
+ return true;
152
+ // "yes please", "go ahead and do it", "no thanks" — first word is an ack
153
+ // and the whole phrase is short enough to be a follow-up not a new task.
154
+ if (normalized.length <= 30) {
155
+ const firstWord = normalized.split(/\s+/)[0] ?? '';
156
+ if (ACK_PHRASES.has(firstWord))
157
+ return true;
158
+ }
159
+ return false;
160
+ }
161
+ /**
162
+ * Returns true when `trimmed` is a bare ack ("y", "n", "ok", "cancel", ...)
163
+ * with no surrounding context — regardless of whether a session.skill exists.
164
+ *
165
+ * Used to short-circuit the classifier when the user types a stray ack but
166
+ * there's no active skill to continue. Without this, after an aborted turn
167
+ * (--from declined, --status passthrough, etc.) the classifier scores the
168
+ * lonely "n" against every skill and shows the coaching picker — exactly
169
+ * the "nonsense like 'yes' → /abap-explain at 35%" failure that ACK_PHRASES
170
+ * was introduced to prevent.
171
+ */
172
+ function isBareAck(trimmed) {
173
+ const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
174
+ return ACK_PHRASES.has(normalized);
175
+ }
176
+ /**
177
+ * Build CoachingPickerParams from a ClassifyResult + SKILL_CATALOG.
178
+ * Picks top-3 candidates (top-1 + top-2 alternates), looks up description
179
+ * and whenToUse from the catalog per entry. Falls back to entry.description
180
+ * if whenToUse is missing (defensive — drift-guard test should prevent this).
181
+ */
182
+ function buildCoachingParams(result, prompt, preSelect) {
183
+ const candidates = [result.skill, ...result.alternates].slice(0, 3);
184
+ const options = candidates.map((skill) => {
185
+ const entry = SKILL_CATALOG.find((e) => e.name === skill);
186
+ return {
187
+ skill,
188
+ // Only the top-1 has a real confidence; alternates currently are skill-
189
+ // name only (Layer B gap). Show top-1's confidence for the first entry,
190
+ // 0 for alternates (rendered as "0%") — a visual cue that these are
191
+ // alternate suggestions, not separately scored.
192
+ confidence: skill === result.skill ? result.confidence : 0,
193
+ whatItDoes: entry?.description ?? 'no description',
194
+ whenToPick: entry?.whenToUse ?? entry?.description ?? 'no description',
195
+ };
196
+ });
197
+ return { prompt, options, preSelect };
198
+ }
199
+ export async function runRepl(opts) {
200
+ printStartupBanner();
201
+ // v0.6 — surface interrupted sessions at startup so the developer doesn't
202
+ // have to remember the session id from the crash UX. Like `git stash list`
203
+ // for resilience: if you have any unfinished work, you see it immediately.
204
+ // Skip when --resume is already in flight (the user is resuming right now).
205
+ if (!opts?.resumedSession) {
206
+ try {
207
+ const { countInterruptedSessions } = await import('./session/store.js');
208
+ const n = await countInterruptedSessions();
209
+ if (n > 0) {
210
+ console.log(chalk.yellow.bold(`⚠ You have ${n} interrupted session${n === 1 ? '' : 's'}.`));
211
+ console.log(chalk.yellow(` Recover with: cspeach --resume (picks the right one from a list)\n`));
212
+ }
213
+ }
214
+ catch { /* startup banner is best-effort */ }
215
+ }
216
+ // Phase 2.1 of Track B — when CSPEACH_PROJECT_CONTEXT=on the dogfooder
217
+ // is on the experimental enrichment path. Surface that visibly so they
218
+ // (a) know it's active and (b) can correlate session behavior with the
219
+ // flag state when running `cspeach analyze project-context` later. The
220
+ // flag is process-scoped, so this prints at most once per CLI launch.
221
+ try {
222
+ const { isFlagOn } = await import('./agent/maybe-build-project-context.js');
223
+ if (isFlagOn()) {
224
+ console.log(chalk.cyan(`[project-context] enabled (cwd=${process.cwd()})`));
225
+ console.log(chalk.dim(' unset CSPEACH_PROJECT_CONTEXT to disable; run `cspeach analyze project-context` later to compare.\n'));
226
+ }
227
+ }
228
+ catch { /* startup banner is best-effort */ }
229
+ // Customer-onboarding (Task 9): if the new auth-file is present, hit /v1/me
230
+ // and render the verbose login banner. Falls through to the legacy keytar
231
+ // flow below when the file is absent (back-compat for pre-`cspeach login`
232
+ // users).
233
+ try {
234
+ const authFile = await readAuthFile();
235
+ if (authFile) {
236
+ const cfgEarly = await loadConfig();
237
+ const meResult = await fetchMe({ proxyUrl: cfgEarly.proxy_url, apiKey: authFile.apiKey });
238
+ if (!meResult.ok) {
239
+ if (meResult.status === 401) {
240
+ console.log(chalk.yellow('Your CSPeach session is invalid or expired. Run `cspeach login` to re-authenticate.'));
241
+ return 1;
242
+ }
243
+ console.log(chalk.dim(`(could not refresh /v1/me — ${meResult.error})`));
244
+ }
245
+ else {
246
+ const banner = formatLoginBanner(meResult.profile);
247
+ console.log(banner.line);
248
+ if (banner.warningLine)
249
+ console.log(chalk.yellow(banner.warningLine));
250
+ }
251
+ }
252
+ }
253
+ catch { /* banner is best-effort — never block REPL startup */ }
254
+ const store = await getStore();
255
+ if (!(await store.isKeychainAvailable())) {
256
+ printKeytarFallbackBanner();
257
+ }
258
+ let apiKey = await store.get();
259
+ if (!apiKey) {
260
+ const entered = await promptPassword({
261
+ message: 'Paste your CSPeach API key (from invite email):',
262
+ mask: '*',
263
+ });
264
+ await store.set(entered);
265
+ apiKey = entered;
266
+ }
267
+ let cfg = await loadConfig();
268
+ // Apply user-configured rendering override BEFORE any shouldUseInk() call.
269
+ setRenderingOverride(cfg.ui.rendering);
270
+ if (Object.keys(cfg.sap).length === 0) {
271
+ console.log(chalk.yellow('\nNo SAP systems configured. Starting onboarding wizard.'));
272
+ await runAddSapWizard();
273
+ cfg = await loadConfig();
274
+ }
275
+ // Task C.10 — surface the current write_mode prominently. advisory-only gets
276
+ // a bold yellow banner (no-writes is a surprising-enough state that the user
277
+ // should not miss it). Other modes get a quiet one-liner so the state is
278
+ // always visible without competing with the peach banner above.
279
+ if (cfg.write_mode === 'advisory-only') {
280
+ console.log(chalk.yellow.bold('⚠ Write mode: ADVISORY (writes proposed, never executed)'));
281
+ console.log(chalk.yellow(' Change via: cspeach config set write_mode approval-gated\n'));
282
+ }
283
+ else {
284
+ console.log(chalk.gray(`Write mode: ${cfg.write_mode}\n`));
285
+ }
286
+ const aliases = Object.keys(cfg.sap);
287
+ // C.1 — --sap-alias flag override skips the interactive picker
288
+ let selectedAlias;
289
+ if (opts?.sapAlias) {
290
+ console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
291
+ selectedAlias = opts.sapAlias;
292
+ }
293
+ else if (opts?.resumedSession?.sap_system) {
294
+ // v0.6 — resumed session knows its SAP. Skip the picker.
295
+ selectedAlias = opts.resumedSession.sap_system;
296
+ console.log(chalk.dim(`Resumed on SAP ${selectedAlias}`));
297
+ }
298
+ else {
299
+ selectedAlias =
300
+ aliases.length === 1
301
+ ? aliases[0]
302
+ : await select({
303
+ message: 'Pick a SAP system:',
304
+ choices: aliases.map((a) => ({ value: a, name: a })),
305
+ });
306
+ }
307
+ const conn = await getConnection(selectedAlias, cfg.sap[selectedAlias]);
308
+ const adt = new AdtClient(conn);
309
+ // v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
310
+ // preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
311
+ // turnInterrupted flag from the prior run. Skipping newSession() avoids
312
+ // overwriting the saved file with an empty fresh one.
313
+ const session = opts?.resumedSession ?? newSession(makeSessionId(), selectedAlias, null, cfg.default_model);
314
+ if (!opts?.resumedSession) {
315
+ await saveSession(session);
316
+ }
317
+ const provider = createProviderForMode(cfg, async () => {
318
+ const key = await store.get();
319
+ if (!key)
320
+ throw new Error('No API key configured');
321
+ return key;
322
+ });
323
+ if (opts?.resumedSession) {
324
+ const turnCount = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
325
+ console.log(chalk.cyan(`\nResumed session ${session.id} on ${selectedAlias} — ${turnCount} prior turn(s).`));
326
+ if (session.turnInterrupted) {
327
+ const reason = session.turnInterruptedReason ?? 'unknown';
328
+ console.log(chalk.yellow(`⚠ Last turn was interrupted (${reason}).`));
329
+ console.log(chalk.yellow(` Type \"continue\" to ask the model to finish where it left off.`));
330
+ }
331
+ console.log(chalk.gray(`Type /exit to quit.\n`));
332
+ }
333
+ else {
334
+ console.log(chalk.gray(`\nSession ${session.id} on ${selectedAlias}. Type /exit to quit.\n`));
335
+ }
336
+ // B.3 — real session-scoped transport accessor. getCurrentTransport() reads the
337
+ // module-scope singleton in repl/current-transport.ts which is mutated by
338
+ // sap_transport_create (success path) and the /transport slash command.
339
+ // Defined at outer scope so both the Ink branch and the classic readline branch
340
+ // can inject it into ToolContext without duplication.
341
+ const currentTransportAccessor = { get: getCurrentTransport };
342
+ // 2026-05-08: pendingDispatch slot for the auto-route-after-picker UX.
343
+ // The dispatch_skill tool writes a slash command here when the user
344
+ // explicitly approved a "Switch to /abap-X" via ask_question. After
345
+ // each runTurn we check this slot and, if set, route that command as
346
+ // the next user submission — no Enter press required. Lives at outer
347
+ // scope so both the Ink and readline branches share the same instance.
348
+ // Cleared at the start of every consume cycle so a stale value never
349
+ // re-fires.
350
+ let pendingDispatchValue = null;
351
+ const pendingDispatchAccessor = {
352
+ get: () => pendingDispatchValue,
353
+ set: (cmd) => { pendingDispatchValue = cmd; },
354
+ };
355
+ // v0.3: Ink-mode REPL branch. When running under a real TTY in REPL mode
356
+ // (not one-shot, not CI), mount the <App /> frame instead of the readline
357
+ // loop. The non-Ink path below is preserved byte-for-byte for piped/CI
358
+ // invocations.
359
+ if (shouldUseInk()) {
360
+ const { render } = await import('ink');
361
+ const { App } = await import('./ui/app.js');
362
+ const React = (await import('react')).default;
363
+ const chunkEmitter = new EventEmitter();
364
+ let done = false;
365
+ let resolveDone = null;
366
+ const donePromise = new Promise((r) => { resolveDone = r; });
367
+ const handleSubmit = async (line) => {
368
+ const trimmed = line.trim();
369
+ if (!trimmed)
370
+ return;
371
+ appendHistory(trimmed);
372
+ if (trimmed === '/exit' || trimmed === '/quit') {
373
+ done = true;
374
+ if (resolveDone)
375
+ resolveDone();
376
+ return;
377
+ }
378
+ if (trimmed === '/help' || trimmed === '/skills') {
379
+ printHelp();
380
+ return;
381
+ }
382
+ // /files — open interactive picker for .cspeach.json artefacts in
383
+ // the workspace folder. After picking a file and an action, prints
384
+ // the synthesized slash command for the user to copy or recall.
385
+ if (trimmed === '/files') {
386
+ try {
387
+ const { openFilePicker } = await import('./repl/file-picker.js');
388
+ const result = await openFilePicker();
389
+ if (result.kind === 'command') {
390
+ chunkEmitter.emit('chunk', chalk.dim(`↪ ${result.command}\n`));
391
+ }
392
+ else if (result.kind === 'empty') {
393
+ // Empty workspace — print the listing so the user sees the
394
+ // workspace path and the "drop something here" hint. Skipped
395
+ // on 'cancelled' so a cancel returns silently to the prompt.
396
+ const { listProjectFiles, ensureWorkspace, formatProjectFileList } = await import('./projects/workspace.js');
397
+ const ws = await ensureWorkspace();
398
+ const files = await listProjectFiles();
399
+ chunkEmitter.emit('chunk', formatProjectFileList(files, ws) + '\n');
400
+ }
401
+ // result.kind === 'cancelled' — print nothing.
402
+ }
403
+ catch (err) {
404
+ chunkEmitter.emit('chunk', chalk.red(`\n[/files] ${err.message}\n`));
405
+ }
406
+ return;
407
+ }
408
+ // /ui — view or change rendering mode. Ink-mode branch mirrors non-Ink.
409
+ if (trimmed === '/ui' || trimmed.startsWith('/ui ')) {
410
+ // Defensive: a config that predates the [ui] section may have cfg.ui === undefined
411
+ // if loaded before loader.ts's deep-merge fix. Initialise to the default.
412
+ if (!cfg.ui)
413
+ cfg.ui = { rendering: 'auto' };
414
+ const arg = trimmed.slice(3).trim().toLowerCase();
415
+ if (arg === '') {
416
+ console.log(`\nCurrent UI mode: ${chalk.cyan.bold(cfg.ui.rendering)} ` +
417
+ `(effective now: ${chalk.yellow(shouldUseInk() ? 'ink' : 'classic')})\n`);
418
+ console.log(chalk.dim(' /ui classic — chalk REPL (default)'));
419
+ console.log(chalk.dim(' /ui ink — Ink frame (opt-in)'));
420
+ console.log(chalk.dim(' /ui auto — choose automatically\n'));
421
+ console.log(chalk.dim('Change takes effect after /exit + relaunch.\n'));
422
+ }
423
+ else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
424
+ cfg.ui.rendering = arg;
425
+ try {
426
+ await saveConfig(cfg);
427
+ console.log(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
428
+ console.log(chalk.dim('Relaunch cspeach to apply.\n'));
429
+ }
430
+ catch (err) {
431
+ console.log(chalk.red(`\n✗ Failed to save config: ${err.message}\n`));
432
+ }
433
+ }
434
+ else {
435
+ console.log(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.\n`));
436
+ }
437
+ return;
438
+ }
439
+ // /reroute — re-dispatch session.lastUserPrompt to a different skill
440
+ if (trimmed === '/reroute' || trimmed.startsWith('/reroute ')) {
441
+ session.awaitingSkillAnswer = false;
442
+ const rr = parseAndValidateReroute(trimmed, session.lastUserPrompt, SKILL_CATALOG);
443
+ if (rr.kind === 'error') {
444
+ console.log(chalk.red(`\n${rr.message}\n`));
445
+ return;
446
+ }
447
+ const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
448
+ console.log(chalk.dim(`↪ Rerouting "${preview}" → /${rr.skill}`));
449
+ if (rr.warning)
450
+ console.log(chalk.dim(` (${rr.warning})`));
451
+ await runTurn({
452
+ provider,
453
+ userMessage: rr.prompt,
454
+ skill: rr.skill,
455
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
456
+ chunkEmitter,
457
+ });
458
+ return;
459
+ }
460
+ // /new — clear any "awaiting skill answer" state so the next prompt
461
+ // goes through the classifier afresh instead of auto-continuing the
462
+ // previous skill's Q&A chain.
463
+ if (trimmed === '/new' || trimmed === '/reset') {
464
+ session.awaitingSkillAnswer = false;
465
+ console.log(chalk.dim('↩ fresh start — next prompt will be classified normally'));
466
+ return;
467
+ }
468
+ // /transport — session current-transport state for Rule 9 (transport isolation)
469
+ if (trimmed === '/transport' || trimmed.startsWith('/transport ')) {
470
+ const arg = trimmed === '/transport' ? '' : trimmed.slice('/transport '.length);
471
+ const msg = handleTransportCommand(arg);
472
+ console.log(chalk.cyan(msg));
473
+ return;
474
+ }
475
+ const parsed = parseShortcut(trimmed);
476
+ // Record the prose body (post-shortcut stripping) for /reroute. Using
477
+ // `parsed.prompt` rather than `trimmed` means:
478
+ // - plain prose: `parsed.prompt === trimmed`, stored as-is
479
+ // - `/radar look at ZFOO`: stored as `look at ZFOO` — a later /reroute
480
+ // replays the body with a new skill (correct)
481
+ // - `@ZCL_ORDER explain this`: stored as `explain this` — @OBJECT
482
+ // context is lost on reroute, acceptable v0.3.1 limitation
483
+ session.lastUserPrompt = parsed.prompt;
484
+ const prompt = parsed.prompt;
485
+ const extractedObject = parsed.object ?? null;
486
+ const previousSkill = session.skill;
487
+ const previousObject = session.last_object ?? null;
488
+ const shortcutSkill = parsed.skill;
489
+ let skill = shortcutSkill;
490
+ if (!skill) {
491
+ // v0.3.1 — if the last skill turn ended with a question, route the
492
+ // user's reply back to the same skill instead of reclassifying. This
493
+ // stops short answers like "1" or "S/4 on-prem" from being treated
494
+ // as brand-new prompts. Type /new to opt out.
495
+ // Use session.skill (the skill of the IMMEDIATELY PREVIOUS turn) —
496
+ // NOT session.last_skill which holds the skill from TWO turns ago
497
+ // and is used as classifier context.
498
+ if ((session.awaitingSkillAnswer || isAckContinuation(trimmed, session.skill)) && session.skill) {
499
+ chunkEmitter.emit('chunk', chalk.dim(`↪ Continuing /${session.skill}\n`));
500
+ skill = session.skill;
501
+ }
502
+ else if (isBareAck(trimmed)) {
503
+ // Bare "y" / "n" / "cancel" with no skill to continue — typing this
504
+ // after an aborted turn is almost always a stray keystroke, NOT a
505
+ // request to start work. Don't burn a classifier round-trip on it.
506
+ chunkEmitter.emit('chunk', chalk.dim('↩ no active skill — type /help or a full prompt\n'));
507
+ return;
508
+ }
509
+ else {
510
+ try {
511
+ const c = await classifyPrompt(prompt, {
512
+ sap_system: selectedAlias,
513
+ last_skill: session.last_skill ?? null,
514
+ last_object: extractedObject ?? session.last_object ?? null,
515
+ });
516
+ const decision = decideRouting(c);
517
+ let pickedResult;
518
+ if (cfg.classifier.safe_mode) {
519
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false));
520
+ }
521
+ else if (decision.kind === 'silent') {
522
+ chunkEmitter.emit('chunk', chalk.dim(`┄ Routed to /${decision.skill}\n`));
523
+ pickedResult = decision.skill;
524
+ }
525
+ else if (decision.kind === 'coach') {
526
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true));
527
+ }
528
+ else {
529
+ pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false));
530
+ }
531
+ if (pickedResult === PICK_CANCELLED) {
532
+ chunkEmitter.emit('chunk', chalk.dim('↩ cancelled — type a new prompt\n'));
533
+ return;
534
+ }
535
+ skill = pickedResult;
536
+ }
537
+ catch (err) {
538
+ chunkEmitter.emit('chunk', chalk.red(`\n[classifier error] ${err.message}\n`));
539
+ return;
540
+ }
541
+ }
542
+ }
543
+ else {
544
+ // Explicit slash-shortcut → user is naming a skill, so clear any
545
+ // pending "awaiting answer" state from a prior turn.
546
+ session.awaitingSkillAnswer = false;
547
+ }
548
+ // D3 — `--status @file ...` is a one-shot pass-through that reads
549
+ // `.cspeach.json` envelopes from disk and prints a summary. Wired for
550
+ // /abap-spec-gap, /abap-design, and /abap-estimate (the renderer in
551
+ // projects/status.ts handles all three artefact types). It must NOT
552
+ // consume model tokens, so we intercept here AFTER skill resolution
553
+ // but BEFORE runTurn. The body lives in `prompt` (post-parseShortcut),
554
+ // e.g. `--status @./foo.cspeach.json`.
555
+ if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate') &&
556
+ /(^|\s)--status(\s|$)/.test(prompt)) {
557
+ try {
558
+ const files = prompt
559
+ .split(/\s+/)
560
+ .filter((tok) => tok.startsWith('@'))
561
+ .map((tok) => tok.slice(1));
562
+ const { runSpecGapStatus } = await import('./commands/spec-gap-status.js');
563
+ await runSpecGapStatus({
564
+ files,
565
+ log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
566
+ });
567
+ }
568
+ catch (err) {
569
+ chunkEmitter.emit('chunk', chalk.red(`\n[--status] ${err.message}\n`));
570
+ }
571
+ session.last_skill = previousSkill ?? null;
572
+ session.last_object = extractedObject ?? previousObject ?? null;
573
+ return;
574
+ }
575
+ try {
576
+ await runTurn({
577
+ provider,
578
+ userMessage: prompt,
579
+ skill: skill,
580
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
581
+ chunkEmitter,
582
+ });
583
+ }
584
+ catch (err) {
585
+ const { emitError } = await import('./ui/error-emitter.js');
586
+ emitError({
587
+ code: 'RUN_TURN_ERROR',
588
+ title: 'Turn failed',
589
+ body: err.message,
590
+ });
591
+ }
592
+ session.last_skill = previousSkill ?? null;
593
+ session.last_object = extractedObject ?? previousObject ?? null;
594
+ // 2026-05-08: post-turn auto-dispatch. The dispatch_skill tool may
595
+ // have queued a slash command (typically after the user picked
596
+ // "Switch to /abap-X" via ask_question). Consume the slot and
597
+ // re-enter handleSubmit with that command — no user keypress.
598
+ // The user's consent was the picker pick.
599
+ const dispatched = pendingDispatchAccessor.get();
600
+ if (dispatched) {
601
+ pendingDispatchAccessor.set(null);
602
+ chunkEmitter.emit('chunk', chalk.dim(`↪ Auto-routed: ${dispatched}\n`));
603
+ // Schedule on next tick so the current handleSubmit's promise
604
+ // resolves cleanly before the next one starts. Recursion via
605
+ // setImmediate keeps the call stack flat across long chains.
606
+ setImmediate(() => { void handleSubmit(dispatched); });
607
+ }
608
+ };
609
+ const { unmount } = render(React.createElement(App, {
610
+ chunkEmitter,
611
+ onSubmit: (line) => { void handleSubmit(line); },
612
+ onSlashTrigger: () => { },
613
+ onHistoryTrigger: () => { },
614
+ getToolCalls: () => session.toolCalls,
615
+ writeMode: cfg.write_mode,
616
+ }));
617
+ await donePromise;
618
+ unmount();
619
+ return 0;
620
+ }
621
+ // --- readline setup (replaces inquirer input()) ---
622
+ //
623
+ // Bracketed-paste: by default readline splits input at every `\n`, so a
624
+ // multi-line paste loses every line after the first. We turn on DEC
625
+ // mode 2004 on the terminal and pipe stdin through a decoder that
626
+ // collapses the content between `\x1b[200~` / `\x1b[201~` markers into
627
+ // a single line before readline sees it. See src/repl/bracketed-paste.ts.
628
+ enableBracketedPaste();
629
+ const pasteInput = new BracketedPasteDecoder();
630
+ process.stdin.pipe(pasteInput);
631
+ const history = loadHistory();
632
+ const rl = readline.createInterface({
633
+ input: pasteInput,
634
+ output: process.stdout,
635
+ terminal: true,
636
+ historySize: HISTORY_SIZE,
637
+ removeHistoryDuplicates: true,
638
+ history, // seed with persisted history from previous sessions
639
+ // 2026-05-01: Tab-completion for slash commands. readline's native
640
+ // completer fires on Tab — type `/abap-r<Tab>` and it expands to the
641
+ // matching skill, or shows the filtered list if multiple. Type `/<Tab>`
642
+ // for the full skill + built-in command catalogue. Non-slash lines get
643
+ // no completion so natural prose isn't disturbed.
644
+ //
645
+ // 2026-05-06: extended to also Tab-complete `@<filename>` tokens
646
+ // against the workspace folder. atCompleter returns null for non-@
647
+ // lines and we fall through to slashCompleter — both completers
648
+ // coexist without stepping on each other.
649
+ completer: (line) => atCompleter(line) ?? slashCompleter(line),
650
+ }); // 'history' seed is supported at runtime but not in @types/node yet
651
+ // Disable bracketed paste on shutdown so the user's shell prompt after
652
+ // CSPeach exits doesn't keep receiving wrapped pastes.
653
+ process.on('exit', () => disableBracketedPaste());
654
+ // Prompt string: 'CSPeach ' + '›' in peach colour
655
+ const promptStr = 'CSPeach ' + chalk.hex(PEACH)('›') + ' ';
656
+ // When we dispatch an inquirer sub-prompt (coaching picker, pickSkill),
657
+ // readline is paused to give inquirer exclusive stdin access. On resolve
658
+ // inquirer sometimes disturbs the stdin stream in a way that makes our
659
+ // readline fire 'close' even though the user did NOT press Ctrl-D. Suppress
660
+ // the exit in that window — real EOF still exits via the catch in the
661
+ // question() loop below.
662
+ let suppressRlClose = false;
663
+ // Tracks the most recent slash-command head we pre-filled with no
664
+ // body. If the user submits the same head twice in a row with no
665
+ // body, we treat the second submit as "cancel" and clear the line
666
+ // instead of re-prompting. Reset to null on any non-empty submit.
667
+ let lastEmptyPrefill = null;
668
+ // Pre-fill state for the next prompt. When the user picks a slash
669
+ // command from the picker (or types a bare slash command), we set
670
+ // this so the next rl.question prints `CSPeach › /<cmd> ` and the
671
+ // user types the body after it. Consumed (cleared + prepended onto
672
+ // line) on every submit. Also cleared by Esc handler below.
673
+ let pendingPrefill = '';
674
+ // 2026-05-08: per-picker guards. Each tracks the exact line that just
675
+ // had THAT picker cancelled — the next iteration skips re-firing the
676
+ // SAME picker on the SAME line, but lets the OTHER picker still fire
677
+ // if its trigger condition is met. Initially conflated into one
678
+ // shared flag, which produced this bug:
679
+ // - Iter 1: at-picker cancelled on "X"; flag = "X".
680
+ // - Iter 2: slash-picker check (different concern) reset the flag.
681
+ // - Iter 2: at-picker check then fires because flag is now null.
682
+ // Splitting into two flags keeps each picker's cancel state
683
+ // independent. Reset by submitting any line different from the one
684
+ // just cancelled.
685
+ let lastCancelledSlashLine = null;
686
+ let lastCancelledAtLine = null;
687
+ // v0.6 — module-scope flag the per-turn SIGINT handler sets to true while
688
+ // a turn is in flight. Both `rl.on('close')` and `rl.on('SIGINT')` below
689
+ // check this flag and bail out so they don't race against the turn-scoped
690
+ // handler that wants to abort the LLM stream and render "✋ Turn cancelled."
691
+ // Without this flag, Ctrl+C during a stream lands in the readline SIGINT
692
+ // path first (line buffer is empty during streaming → "Goodbye" + exit),
693
+ // killing the CLI before the turn handler can do anything useful.
694
+ let turnInProgress = false;
695
+ rl.on('close', () => {
696
+ if (suppressRlClose)
697
+ return;
698
+ if (turnInProgress)
699
+ return; // turn handler owns the abort + UX
700
+ console.log('\nGoodbye.\n');
701
+ process.exit(0);
702
+ });
703
+ // 2026-05-08: handle Ctrl+C explicitly. Without a 'SIGINT' listener
704
+ // Node's readline default fires — which closes the readline. That is
705
+ // what made Ctrl+C inside an inquirer sub-prompt (file picker, skill
706
+ // picker, approval) drop the user back to the shell with a "Goodbye"
707
+ // even though inquirer had already cleanly cancelled the sub-prompt.
708
+ // The mere presence of this listener suppresses Node's default close.
709
+ //
710
+ // Semantic (refined 2026-05-08 after live feedback "we cannot escape
711
+ // from here"): the empty-prompt case is the user trying to leave. The
712
+ // line-has-content case is the user trying to clear what they typed.
713
+ // - Empty prompt + Ctrl+C → exit cleanly (Goodbye)
714
+ // - Non-empty prompt + Ctrl+C → clear current line, redraw prompt
715
+ // - Inquirer sub-prompt + Ctrl+C → cancel that sub-prompt only
716
+ // (inquirer's own SIGINT handler throws; this listener returns
717
+ // early so we don't ALSO close readline behind it)
718
+ // - Ctrl+D / /exit / /quit → exit cleanly (existing paths)
719
+ rl.on('SIGINT', () => {
720
+ if (suppressRlClose)
721
+ return;
722
+ // v0.6 — turn-scoped SIGINT handler (registered just before runTurn
723
+ // and torn down right after) takes precedence. If a turn is in flight,
724
+ // the user wants to interrupt THE TURN, not exit the CLI. Returning
725
+ // early lets the turn handler run its abort + "✋ Turn cancelled." UX.
726
+ if (turnInProgress)
727
+ return;
728
+ // Inspect the readline's internal line buffer + our prefill state.
729
+ // Both empty → user is at a blank prompt and wants out. Either has
730
+ // content → user wants to clear what they typed.
731
+ const currentLine = rl.line ?? '';
732
+ const isEmpty = currentLine.length === 0 && pendingPrefill.length === 0;
733
+ if (isEmpty) {
734
+ console.log('\nGoodbye.\n');
735
+ rl.close();
736
+ process.exit(0);
737
+ }
738
+ process.stdout.write('\n');
739
+ pendingPrefill = '';
740
+ rl
741
+ .write(null, { ctrl: true, name: 'u' });
742
+ rl.prompt();
743
+ });
744
+ // 2026-05-02: bind bare Esc → clear-line. Listen on process.stdin
745
+ // DIRECTLY rather than on the pasteInput stream — bracketed-paste
746
+ // decoder holds bytes that are prefixes of paste markers, including
747
+ // a bare 0x1B (since `\x1b[200~` starts with 0x1B). A 50ms flush
748
+ // timer was meant to release the byte but in practice the timer's
749
+ // push out of the Transform doesn't always re-trigger emitKeypress
750
+ // on downstream consumers. Listening directly on process.stdin
751
+ // bypasses the decoder entirely for keypress detection — the
752
+ // raw byte arrives, we parse it, and we act on it. The decoder
753
+ // still processes the same byte for readline's data path; the
754
+ // duplicate read is harmless because readline ignores bare Esc.
755
+ //
756
+ // Skipped while inquirer is active: inquirer takes over stdin in
757
+ // raw mode and owns Esc semantics. The suppressRlClose flag is set
758
+ // by inquirer-guard before any sub-prompt opens.
759
+ // 2026-05-02: detect bare Esc by raw byte rather than keypress event.
760
+ // The keypress event chain (emitKeypressEvents → 'keypress' on
761
+ // process.stdin OR pasteInput) was unreliable here — the bracketed-
762
+ // paste decoder buffers prefix-of-marker bytes (Esc is a strict
763
+ // prefix of `\x1b[200~`), and the alternate stdin-listener path
764
+ // didn't fire either. Direct byte inspection on stdin is the most
765
+ // reliable surface: a single-byte chunk equal to 0x1B is a bare Esc.
766
+ // Multi-byte chunks (arrow keys, paste markers) arrive together and
767
+ // start with 0x1B but include follow-up bytes, so we can distinguish
768
+ // them by chunk.length and skip those.
769
+ //
770
+ // Caveat on chunk boundaries: terminals occasionally split escape
771
+ // sequences across chunks. To guard against false-positives, we wait
772
+ // a short window after seeing a lone 0x1B; if a follow-up byte
773
+ // arrives within that window, the original is part of a sequence
774
+ // and we don't act. If not, it was a deliberate Esc.
775
+ let escWaitTimer = null;
776
+ const handleBareEscape = () => {
777
+ if (suppressRlClose)
778
+ return;
779
+ const rlWrite = rl.write;
780
+ // Clear the editable buffer (everything typed after the prefill).
781
+ rlWrite.call(rl, null, { ctrl: true, name: 'u' });
782
+ if (pendingPrefill) {
783
+ // 2026-05-02: { name: 'return' } via rl.write does NOT reliably
784
+ // trigger readline's _line() — depending on Node version the
785
+ // key handler ignores it. Most direct way to force the current
786
+ // rl.question() to resolve is to emit the 'line' event on rl
787
+ // manually with an empty string. That fires the question's
788
+ // internal callback, the awaited promise resolves with '', and
789
+ // the loop iterates to redraw a clean prompt.
790
+ pendingPrefill = '';
791
+ setImmediate(() => {
792
+ rl.emit('line', '');
793
+ });
794
+ }
795
+ };
796
+ // 2026-05-02: stdin 'data' listener for bare-Esc detection. Inquirer
797
+ // (search picker / approval / safety prompts) strips data listeners
798
+ // on release, so we cannot rely on a one-time attach — the listener
799
+ // would silently disappear after the first picker open. Wrap the
800
+ // attach logic in a function and call it both initially AND at the
801
+ // top of every REPL iteration. Each call removes any previous copy
802
+ // of OUR listener (so we don't accumulate over time) and re-adds it.
803
+ const debugEsc = process.env['CSPEACH_DEBUG_ESC'] === '1';
804
+ const escDataListener = (chunk) => {
805
+ if (debugEsc) {
806
+ const hex = Array.from(chunk).slice(0, 8).map((b) => b.toString(16).padStart(2, '0')).join(' ');
807
+ process.stderr.write(`\n[stdin] len=${chunk.length} bytes=${hex}${chunk.length > 8 ? '...' : ''}\n`);
808
+ }
809
+ if (escWaitTimer && (chunk.length > 1 || chunk[0] !== 0x1B)) {
810
+ if (debugEsc)
811
+ process.stderr.write('[stdin] cancelling pending esc-wait (follow-up byte)\n');
812
+ clearTimeout(escWaitTimer);
813
+ escWaitTimer = null;
814
+ }
815
+ if (chunk.length === 1 && chunk[0] === 0x1B && !escWaitTimer) {
816
+ if (debugEsc)
817
+ process.stderr.write('[stdin] arming esc-wait timer\n');
818
+ escWaitTimer = setTimeout(() => {
819
+ escWaitTimer = null;
820
+ if (debugEsc)
821
+ process.stderr.write('[stdin] esc confirmed, firing handler\n');
822
+ handleBareEscape();
823
+ }, 30);
824
+ escWaitTimer.unref?.();
825
+ }
826
+ };
827
+ const attachEscDetector = () => {
828
+ // Attach to BOTH process.stdin and pasteInput. Inquirer prompts
829
+ // strip listeners from process.stdin on release, so a stdin-only
830
+ // listener silently breaks after the first sub-prompt. pasteInput
831
+ // is OUR transform stream that inquirer doesn't touch — listening
832
+ // there is a robust fallback. Either listener firing triggers the
833
+ // same handler; the duplicate is harmless because the timer arms
834
+ // only once per chunk.
835
+ process.stdin.removeListener('data', escDataListener);
836
+ process.stdin.on('data', escDataListener);
837
+ pasteInput.removeListener('data', escDataListener);
838
+ pasteInput.on('data', escDataListener);
839
+ if (debugEsc) {
840
+ process.stderr.write(`[attach] stdin listeners=${process.stdin.listenerCount('data')} ` +
841
+ `pasteInput listeners=${pasteInput.listenerCount('data')} ` +
842
+ `stdin paused=${process.stdin.isPaused?.() ?? 'n/a'}\n`);
843
+ }
844
+ };
845
+ attachEscDetector();
846
+ // Register the main readline with the inquirer-guard so any tool handler
847
+ // (ask_question, request_approval, …) that opens an inquirer sub-prompt
848
+ // can pause us and suppress the spurious 'close' inquirer emits on
849
+ // release. See src/repl/inquirer-guard.ts for the failure mode this
850
+ // prevents (CSPeach silently exiting after a tool-mediated prompt).
851
+ registerReadline(rl, (v) => {
852
+ suppressRlClose = v;
853
+ });
854
+ // 2026-05-08: one-time workspace-default migration. The default workspace
855
+ // changed from `~/CSPeach/` to project-rooted `<git-root>/.cspeach/`.
856
+ // Existing users with files in ~/CSPeach/ get asked once how to handle
857
+ // them. After that the flag is set and this becomes a no-op.
858
+ // Wrapped in withInquirer because it opens a select() sub-prompt.
859
+ await maybePromptWorkspaceMigration({
860
+ prompt: async ({ fileCount, oldWorkspace, newWorkspace }) => withInquirer(() => select({
861
+ message: `Found ${fileCount} file(s) in ${oldWorkspace}. CSPeach now defaults to project-rooted workspaces — what should we do?`,
862
+ default: 'move',
863
+ choices: [
864
+ { value: 'move', name: `Move all to ${newWorkspace} (recommended for this project)` },
865
+ { value: 'keep_global', name: `Keep using ${oldWorkspace} as global workspace (pins config workspace_folder)` },
866
+ { value: 'leave', name: `Leave ${oldWorkspace} alone — start fresh in ${newWorkspace}` },
867
+ ],
868
+ })),
869
+ log: (line) => console.log(chalk.dim(line)),
870
+ });
871
+ while (true) {
872
+ // Re-attach the bare-Esc detector every iteration. Inquirer prompts
873
+ // (slash-picker, approval, ask_question, safety-confirm) remove
874
+ // listeners on release; without this re-attach Esc detection
875
+ // silently breaks after the first sub-prompt.
876
+ attachEscDetector();
877
+ let line;
878
+ // 2026-05-08: pre-prompt auto-dispatch. If the previous turn's
879
+ // dispatch_skill tool queued a slash command, skip the rl.question
880
+ // wait and feed that command to the loop directly — the user's
881
+ // consent was the picker pick, no Enter required. Cleared
882
+ // immediately so a stale value never re-fires.
883
+ const dispatchedCmd = pendingDispatchAccessor.get();
884
+ if (dispatchedCmd) {
885
+ pendingDispatchAccessor.set(null);
886
+ console.log(chalk.dim(`↪ Auto-routed: ${dispatchedCmd}`));
887
+ line = dispatchedCmd;
888
+ }
889
+ else {
890
+ const effectivePrompt = pendingPrefill ? promptStr + pendingPrefill : promptStr;
891
+ try {
892
+ line = await rl.question(effectivePrompt);
893
+ }
894
+ catch {
895
+ // rl.question rejects for two distinct reasons:
896
+ // 1. readline genuinely closed (real EOF / Ctrl+D / rl.close()
897
+ // called) → exit cleanly with the standard goodbye.
898
+ // 2. transient AbortError when a SIGINT bubbled up after an
899
+ // inquirer sub-prompt consumed Ctrl+C. The readline is still
900
+ // alive, the user just wanted to cancel the current line —
901
+ // redraw the prompt and continue the loop.
902
+ // The 'closed' property on Node's readline interface (Node 18+)
903
+ // discriminates the two cases reliably.
904
+ const rlAny = rl;
905
+ if (rlAny.closed) {
906
+ console.log('\nGoodbye.\n');
907
+ rl.close();
908
+ return 0;
909
+ }
910
+ pendingPrefill = '';
911
+ continue;
912
+ }
913
+ }
914
+ // If a pre-fill was active, prepend it onto the user-typed body
915
+ // to recover the full command. Then clear the pre-fill — it's a
916
+ // one-shot signal, consumed on the next submit.
917
+ if (pendingPrefill) {
918
+ line = pendingPrefill + line;
919
+ pendingPrefill = '';
920
+ }
921
+ const trimmed = line.trim();
922
+ if (!trimmed)
923
+ continue;
924
+ // Persist non-empty input to history file.
925
+ appendHistory(trimmed);
926
+ // 2026-05-01 fix: rl.question() does NOT auto-populate readline's internal
927
+ // history (only the 'line' event does). Without this manual push, arrow-up
928
+ // cycles only through the file-loaded seed from session start — commands
929
+ // typed THIS session never appear. Push to the front, trim to historySize,
930
+ // and skip exact-dup-of-most-recent so the buffer stays clean.
931
+ const rlHistory = rl.history;
932
+ if (Array.isArray(rlHistory)) {
933
+ if (rlHistory[0] !== trimmed)
934
+ rlHistory.unshift(trimmed);
935
+ if (rlHistory.length > HISTORY_SIZE)
936
+ rlHistory.length = HISTORY_SIZE;
937
+ }
938
+ // /cancel — the reliable way to escape a pre-filled slash command.
939
+ // Works in two contexts:
940
+ // 1. Standalone: user is at a clean prompt and types `/cancel` —
941
+ // no-op except for a tiny hint that there's nothing to cancel.
942
+ // 2. After a pre-fill: user is at `CSPeach › /abap-design ` and
943
+ // types `/cancel` as the body. After pendingPrefill prepends,
944
+ // the line ends with ` /cancel` — we detect that pattern and
945
+ // treat it as "abandon the pre-fill, return to clean prompt".
946
+ //
947
+ // Esc would be the natural shortcut for this, but the bracketed-
948
+ // paste pipe + inquirer's stdin handling on Windows interferes
949
+ // with detecting bare-Esc reliably. /cancel is the cross-terminal
950
+ // fallback that always works.
951
+ if (trimmed === '/cancel') {
952
+ console.log(chalk.dim('Nothing to cancel — type a prompt or pick a command with /'));
953
+ continue;
954
+ }
955
+ if (trimmed.endsWith(' /cancel')) {
956
+ console.log(chalk.dim('↩ cancelled'));
957
+ continue;
958
+ }
959
+ if (trimmed === '/exit' || trimmed === '/quit') {
960
+ console.log('Goodbye.\n');
961
+ rl.close();
962
+ return 0;
963
+ }
964
+ // /help and /skills show the skill catalog flat-list.
965
+ if (trimmed === '/help' || trimmed === '/skills') {
966
+ printHelp();
967
+ continue;
968
+ }
969
+ // /files — open interactive picker for .cspeach.json artefacts in
970
+ // the workspace folder. After picking a file and an action, pre-fill
971
+ // the synthesized slash command into the NEXT prompt via pendingPrefill
972
+ // (the same mechanism Laeeq's slash-pre-fill uses) so the user just
973
+ // presses Enter to run it. Editing before Enter is supported — user
974
+ // can append flags or change the command head.
975
+ //
976
+ // Earlier revision used rl.history.unshift to stage the command for
977
+ // up-arrow recall, which silently failed (history-array writes don't
978
+ // surface to the next up-arrow press in this readline configuration).
979
+ // pendingPrefill is the proven path — it bakes the line into the
980
+ // prompt string itself so there's no race with readline's redraw.
981
+ if (trimmed === '/files') {
982
+ try {
983
+ const { openFilePicker } = await import('./repl/file-picker.js');
984
+ const result = await openFilePicker();
985
+ if (result.kind === 'command') {
986
+ console.log(chalk.dim(`↪ Picked. Press Enter to run, or edit first.`));
987
+ pendingPrefill = result.command;
988
+ }
989
+ else if (result.kind === 'empty') {
990
+ // Empty workspace — print the listing so the user sees the
991
+ // workspace path and the "drop something here" hint. Skipped
992
+ // on 'cancelled' so a cancel returns silently to the prompt.
993
+ const { listProjectFiles, ensureWorkspace, formatProjectFileList } = await import('./projects/workspace.js');
994
+ const ws = await ensureWorkspace();
995
+ const files = await listProjectFiles();
996
+ console.log(formatProjectFileList(files, ws));
997
+ }
998
+ // result.kind === 'cancelled' — print nothing.
999
+ }
1000
+ catch (err) {
1001
+ console.log(chalk.red(`\n[/files] ${err.message}\n`));
1002
+ }
1003
+ continue;
1004
+ }
1005
+ // /ui — view or change the rendering mode. Takes effect on next launch.
1006
+ if (trimmed === '/ui' || trimmed.startsWith('/ui ')) {
1007
+ // Defensive: a config that predates the [ui] section may have cfg.ui === undefined
1008
+ // if loaded before loader.ts's deep-merge fix. Initialise to the default.
1009
+ if (!cfg.ui)
1010
+ cfg.ui = { rendering: 'auto' };
1011
+ const arg = trimmed.slice(3).trim().toLowerCase();
1012
+ if (arg === '') {
1013
+ console.log(`\nCurrent UI mode: ${chalk.cyan.bold(cfg.ui.rendering)} ` +
1014
+ `(effective now: ${chalk.yellow(shouldUseInk() ? 'ink' : 'classic')})\n`);
1015
+ console.log(chalk.dim(' /ui classic — chalk REPL (v0.2 style — default, fully tested)'));
1016
+ console.log(chalk.dim(' /ui ink — Ink frame (v0.3 — opt-in while polish is pending)'));
1017
+ console.log(chalk.dim(' /ui auto — choose automatically based on terminal'));
1018
+ console.log(chalk.dim('\nChanges are written to ~/.cspeach/config.toml and'));
1019
+ console.log(chalk.dim('take effect the next time you launch cspeach.\n'));
1020
+ }
1021
+ else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
1022
+ cfg.ui.rendering = arg;
1023
+ try {
1024
+ await saveConfig(cfg);
1025
+ console.log(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
1026
+ console.log(chalk.dim('Relaunch cspeach (`/exit` then `cspeach`) to apply.\n'));
1027
+ }
1028
+ catch (err) {
1029
+ console.log(chalk.red(`\n✗ Failed to save config: ${err.message}\n`));
1030
+ }
1031
+ }
1032
+ else {
1033
+ console.log(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.\n`));
1034
+ }
1035
+ continue;
1036
+ }
1037
+ // /reroute — re-dispatch session.lastUserPrompt to a different skill
1038
+ if (trimmed === '/reroute' || trimmed.startsWith('/reroute ')) {
1039
+ session.awaitingSkillAnswer = false;
1040
+ const rr = parseAndValidateReroute(trimmed, session.lastUserPrompt, SKILL_CATALOG);
1041
+ if (rr.kind === 'error') {
1042
+ console.log(chalk.red(`\n${rr.message}\n`));
1043
+ continue;
1044
+ }
1045
+ const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
1046
+ console.log(chalk.dim(`↪ Rerouting "${preview}" → /${rr.skill}`));
1047
+ if (rr.warning)
1048
+ console.log(chalk.dim(` (${rr.warning})`));
1049
+ await runTurn({
1050
+ provider,
1051
+ userMessage: rr.prompt,
1052
+ skill: rr.skill,
1053
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1054
+ });
1055
+ continue;
1056
+ }
1057
+ // /new — clear "awaiting skill answer" state so the next prompt is
1058
+ // classified afresh instead of continuing the previous skill's Q&A chain.
1059
+ if (trimmed === '/new' || trimmed === '/reset') {
1060
+ session.awaitingSkillAnswer = false;
1061
+ console.log(chalk.dim('↩ fresh start — next prompt will be classified normally'));
1062
+ continue;
1063
+ }
1064
+ // /transport — session current-transport state for Rule 9 (transport isolation)
1065
+ if (trimmed === '/transport' || trimmed.startsWith('/transport ')) {
1066
+ const arg = trimmed === '/transport' ? '' : trimmed.slice('/transport '.length);
1067
+ const msg = handleTransportCommand(arg);
1068
+ console.log(chalk.cyan(msg));
1069
+ continue;
1070
+ }
1071
+ // 2026-05-01: live slash-command picker. When the user types something
1072
+ // that LOOKS like a slash command but isn't an exact match — `/`,
1073
+ // `/abap-r`, `/fix`, etc. — open a search-as-you-type picker
1074
+ // pre-filtered to whatever they typed. Replaces the Tab-cycle
1075
+ // autocomplete which scrolled by command names rather than letting
1076
+ // the user filter and arrow-key through them. An exact match (like
1077
+ // `/abap-refactor` or `/abap-refactor look at ZFOO`) skips the
1078
+ // picker and runs through the existing routing.
1079
+ let resolvedTrimmed = trimmed;
1080
+ if (trimmed.startsWith('/') && !isExactSlashMatch(trimmed)) {
1081
+ if (lastCancelledSlashLine === trimmed) {
1082
+ // 2026-05-08: identical resubmit after cancel = user wants to
1083
+ // drop the line entirely (cancel really means cancel). Clear
1084
+ // the prefilled buffer so iter 3 starts fresh, clear the
1085
+ // guard, and skip dispatch.
1086
+ lastCancelledSlashLine = null;
1087
+ console.log(chalk.dim('↩ dropped'));
1088
+ process.nextTick(() => prefillReadline(rl, ''));
1089
+ continue;
1090
+ }
1091
+ // Extract the head after the slash, drop any arguments. Picker
1092
+ // filters on this; once user picks, we re-attach the arguments.
1093
+ const headEnd = trimmed.search(/\s/);
1094
+ const head = headEnd === -1 ? trimmed : trimmed.slice(0, headEnd);
1095
+ const rest = headEnd === -1 ? '' : trimmed.slice(headEnd);
1096
+ const prefix = head.slice(1); // drop the leading /
1097
+ const picked = await openSlashPicker(prefix);
1098
+ if (picked === null) {
1099
+ console.log(chalk.dim('↩ cancelled — edit, or press Enter again to drop'));
1100
+ lastCancelledSlashLine = trimmed;
1101
+ process.nextTick(() => prefillReadline(rl, trimmed));
1102
+ continue;
1103
+ }
1104
+ lastCancelledSlashLine = null;
1105
+ resolvedTrimmed = `/${picked}${rest}`;
1106
+ }
1107
+ else if (lastCancelledSlashLine !== null && lastCancelledSlashLine !== trimmed) {
1108
+ // User edited the previously-cancelled line into something
1109
+ // different — re-arm the slash-picker for future cancels.
1110
+ lastCancelledSlashLine = null;
1111
+ }
1112
+ // 2026-05-08: live `@<filename>` picker. When the user submits a line
1113
+ // ending in a partial / unresolved @-token (e.g. `/abap-rap --from @`
1114
+ // or `/abap-rap --from @b9b`), open a search-as-you-type picker
1115
+ // pre-filtered to whatever they typed. Mirrors the slash-picker
1116
+ // pattern. Tab still works (atCompleter) for users who prefer a
1117
+ // quick list without screen takeover; this picker is for Enter.
1118
+ {
1119
+ const atPrefix = pendingAtTokenPrefix(resolvedTrimmed);
1120
+ if (atPrefix !== null) {
1121
+ if (lastCancelledAtLine === resolvedTrimmed) {
1122
+ // 2026-05-08: identical resubmit after cancel = drop entirely.
1123
+ // Without this the line went to the LLM with a bare/partial
1124
+ // @ — wasted a turn and confused the user who'd just cancelled.
1125
+ lastCancelledAtLine = null;
1126
+ console.log(chalk.dim('↩ dropped'));
1127
+ process.nextTick(() => prefillReadline(rl, ''));
1128
+ continue;
1129
+ }
1130
+ const pickedFile = await openAtPicker(atPrefix);
1131
+ if (pickedFile === null) {
1132
+ console.log(chalk.dim('↩ cancelled — edit, or press Enter again to drop'));
1133
+ lastCancelledAtLine = resolvedTrimmed;
1134
+ process.nextTick(() => prefillReadline(rl, resolvedTrimmed));
1135
+ continue;
1136
+ }
1137
+ lastCancelledAtLine = null;
1138
+ // Substitute the rightmost @<prefix> with the picked filename.
1139
+ resolvedTrimmed = resolvedTrimmed.replace(/@\S*$/, `@${pickedFile}`);
1140
+ }
1141
+ else if (lastCancelledAtLine !== null && lastCancelledAtLine !== resolvedTrimmed) {
1142
+ // User edited the previously-cancelled at-line into something
1143
+ // different — re-arm the at-picker for future cancels.
1144
+ lastCancelledAtLine = null;
1145
+ }
1146
+ }
1147
+ // 2026-05-01: a bare slash command with no body (e.g. just
1148
+ // `/abap-design`) used to dispatch with an empty prompt, which
1149
+ // the LLM API rejects as "messages.0: user messages must have
1150
+ // non-empty content". Detect this case and pre-fill readline
1151
+ // with `<command> ` (trailing space) so the user can keep
1152
+ // typing their actual prompt. Built-in commands (/exit, /help,
1153
+ // /transport, etc.) already returned earlier — this only
1154
+ // catches skill routes.
1155
+ //
1156
+ // 2026-05-02: simplified — the only way to escape a pre-fill is
1157
+ // Esc / Ctrl+U / Ctrl+C. Pressing Enter without adding a body
1158
+ // just re-prints the hint and re-fills (no destructive cancel).
1159
+ // Pressing Enter twice used to clear, but users expected Enter
1160
+ // to be a no-op when there's nothing to submit, so the
1161
+ // double-Enter cancel was confusing.
1162
+ if (resolvedTrimmed.startsWith('/')) {
1163
+ const splitIdx = resolvedTrimmed.search(/\s/);
1164
+ const bodyAfter = splitIdx === -1 ? '' : resolvedTrimmed.slice(splitIdx).trim();
1165
+ if (bodyAfter.length === 0) {
1166
+ const head = splitIdx === -1 ? resolvedTrimmed : resolvedTrimmed.slice(0, splitIdx);
1167
+ // Show hint only on the FIRST pre-fill of this command. If the
1168
+ // user re-submits the empty pre-fill, just silently re-fill
1169
+ // without re-spamming the hint line. Tracked via lastEmptyPrefill.
1170
+ if (lastEmptyPrefill !== head) {
1171
+ console.log(chalk.dim(`↪ ${head} — type your prompt and press Enter ` +
1172
+ chalk.dim(`(or "/cancel" to abandon)`)));
1173
+ lastEmptyPrefill = head;
1174
+ }
1175
+ // 2026-05-02: bake the pre-fill into the next prompt string
1176
+ // instead of trying to seed the readline buffer post-hoc.
1177
+ // Buffer manipulation (rl.write / direct line/cursor +
1178
+ // _refreshLine) raced with rl.question's own prompt drawing
1179
+ // and produced double prompts / cursor at start / typing
1180
+ // wiping the command. Including the pre-fill in the prompt
1181
+ // means readline only manages the body the user types after
1182
+ // it — no race, no seeded buffer to lose.
1183
+ pendingPrefill = `${head} `;
1184
+ continue;
1185
+ }
1186
+ }
1187
+ // Any non-empty submit clears the empty-prefill latch.
1188
+ lastEmptyPrefill = null;
1189
+ const parsed = parseShortcut(resolvedTrimmed);
1190
+ // Record the prose body (post-shortcut stripping) for /reroute. Using
1191
+ // `parsed.prompt` rather than `trimmed` means:
1192
+ // - plain prose: `parsed.prompt === trimmed`, stored as-is
1193
+ // - `/radar look at ZFOO`: stored as `look at ZFOO` — a later /reroute
1194
+ // replays the body with a new skill (correct)
1195
+ // - `@ZCL_ORDER explain this`: stored as `explain this` — @OBJECT
1196
+ // context is lost on reroute, acceptable v0.3.1 limitation
1197
+ session.lastUserPrompt = parsed.prompt;
1198
+ const prompt = parsed.prompt;
1199
+ const extractedObject = parsed.object ?? null;
1200
+ const previousSkill = session.skill; // snapshot before this turn dispatches
1201
+ const previousObject = session.last_object ?? null; // carries from two turns ago
1202
+ let skill = parsed.skill;
1203
+ if (!skill) {
1204
+ // v0.3.1 — if the last skill turn ended with a question, route the
1205
+ // user's reply back to the same skill instead of reclassifying. This
1206
+ // stops short answers like "1" or "S/4 on-prem" from being treated
1207
+ // as brand-new prompts. Type /new to opt out.
1208
+ // Use session.skill (the skill of the IMMEDIATELY PREVIOUS turn) —
1209
+ // NOT session.last_skill which holds the skill from TWO turns ago
1210
+ // and is used as classifier context.
1211
+ if ((session.awaitingSkillAnswer || isAckContinuation(trimmed, session.skill)) && session.skill) {
1212
+ console.log(chalk.dim(`↪ Continuing /${session.skill}`));
1213
+ skill = session.skill;
1214
+ }
1215
+ else if (isBareAck(trimmed)) {
1216
+ // Bare "y" / "n" / "cancel" with no skill to continue — almost always
1217
+ // a stray keystroke after an aborted turn, not a request to start
1218
+ // work. Don't burn a classifier round-trip on it.
1219
+ console.log(chalk.dim('↩ no active skill — type /help or a full prompt'));
1220
+ continue;
1221
+ }
1222
+ else {
1223
+ try {
1224
+ const c = await classifyPrompt(prompt, {
1225
+ sap_system: selectedAlias,
1226
+ last_skill: session.last_skill ?? null,
1227
+ last_object: extractedObject ?? session.last_object ?? null,
1228
+ });
1229
+ const decision = decideRouting(c);
1230
+ let pickedResult;
1231
+ // Coaching picker path — inquirer takes over stdin. Use the shared
1232
+ // withInquirer guard so the suppress-close window stays active
1233
+ // across the close-fire tick (inline guard cleared the flag
1234
+ // synchronously, leaking the close-fire and dropping CSPeach to
1235
+ // the shell mid-session).
1236
+ const runPicker = async (params) => {
1237
+ return withInquirer(() => coachingPickerClassic(params));
1238
+ };
1239
+ if (cfg.classifier.safe_mode) {
1240
+ // Safe mode overrides the decision — always open the full picker.
1241
+ pickedResult = await runPicker(buildCoachingParams(c, trimmed, false));
1242
+ }
1243
+ else if (decision.kind === 'silent') {
1244
+ console.log(chalk.dim(`┄ Routed to /${decision.skill}`));
1245
+ pickedResult = decision.skill;
1246
+ }
1247
+ else {
1248
+ const preSelect = decision.kind === 'coach';
1249
+ pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect));
1250
+ }
1251
+ if (pickedResult === PICK_CANCELLED) {
1252
+ console.log(chalk.dim('↩ cancelled — type a new prompt'));
1253
+ continue;
1254
+ }
1255
+ skill = pickedResult;
1256
+ }
1257
+ catch (err) {
1258
+ console.log(chalk.red(`\n[classifier error] ${err.message}`));
1259
+ continue;
1260
+ }
1261
+ }
1262
+ }
1263
+ else {
1264
+ // Explicit slash-shortcut → user is naming a skill. Clear any pending
1265
+ // "awaiting answer" state from a prior turn.
1266
+ session.awaitingSkillAnswer = false;
1267
+ }
1268
+ // D3 — `--status @file ...` reads envelope files from disk and prints a
1269
+ // status summary without invoking the model. Wired for /abap-spec-gap,
1270
+ // /abap-design, and /abap-estimate (renderer in projects/status.ts is
1271
+ // type-agnostic). Intercept after skill resolution, before runTurn. The
1272
+ // prompt body (post-parseShortcut) looks like `--status @./foo.cspeach.json`.
1273
+ if ((skill === 'abap-spec-gap' || skill === 'abap-design' || skill === 'abap-estimate') &&
1274
+ /(^|\s)--status(\s|$)/.test(prompt)) {
1275
+ try {
1276
+ const files = prompt
1277
+ .split(/\s+/)
1278
+ .filter((tok) => tok.startsWith('@'))
1279
+ .map((tok) => tok.slice(1));
1280
+ const { runSpecGapStatus } = await import('./commands/spec-gap-status.js');
1281
+ await runSpecGapStatus({
1282
+ files,
1283
+ log: (...lines) => lines.forEach((l) => console.log(l)),
1284
+ });
1285
+ }
1286
+ catch (err) {
1287
+ console.log(chalk.red(`\n[--status] ${err.message}\n`));
1288
+ }
1289
+ session.last_skill = previousSkill ?? null;
1290
+ session.last_object = extractedObject ?? previousObject ?? null;
1291
+ continue;
1292
+ }
1293
+ const turnStart = Date.now();
1294
+ // H1 — `session.usage` is session-LIFETIME (initialised once in
1295
+ // newSession, saved across turns). The footer's "this turn" label was
1296
+ // misleading — it actually showed cumulative lifetime tokens. Snapshot
1297
+ // the lifetime total BEFORE runTurn, then compute the per-turn delta
1298
+ // after runTurn returns. This is Option A (per-turn local) — preferred
1299
+ // over Option B (changing label to "lifetime") because users care about
1300
+ // what the turn they just ran cost them, not the session running total.
1301
+ const lifetimeTokensBefore = session.usage.input_tokens + session.usage.output_tokens;
1302
+ // v0.6 — per-turn SIGINT hard-interrupt + AbortController.
1303
+ //
1304
+ // Critical: Node readline with `terminal: true` (which we use) puts
1305
+ // stdin in raw mode. In raw mode, Ctrl+C is INTERCEPTED by readline
1306
+ // and emitted as a 'SIGINT' event on the readline INSTANCE — no OS
1307
+ // SIGINT signal reaches the process. So `process.on('SIGINT', ...)`
1308
+ // would never fire here; we must listen on `rl` instead.
1309
+ //
1310
+ // Both rl listeners fire when Ctrl+C is pressed: the existing handler
1311
+ // (above, line ~708) returns early because turnInProgress=true, and
1312
+ // this turn-scoped handler does the actual abort + UX. Cleared in the
1313
+ // finally so post-turn Ctrl+C falls back to the existing exit path.
1314
+ turnInProgress = true;
1315
+ const turnAbort = new AbortController();
1316
+ let interruptedByUser = false;
1317
+ let turnSigintFired = false;
1318
+ const onSigint = () => {
1319
+ if (turnSigintFired || turnAbort.signal.aborted)
1320
+ return;
1321
+ turnSigintFired = true;
1322
+ interruptedByUser = true;
1323
+ process.stderr.write(chalk.yellow('\n⚠ Interrupting turn — saving partial work...\n'));
1324
+ turnAbort.abort();
1325
+ // Remove ourselves so a SECOND Ctrl+C falls through to the
1326
+ // existing rl SIGINT handler (which clears the line / exits) once
1327
+ // turnInProgress flips back to false in the finally.
1328
+ rl.removeListener('SIGINT', onSigint);
1329
+ };
1330
+ rl.on('SIGINT', onSigint);
1331
+ try {
1332
+ await runTurn({
1333
+ provider,
1334
+ userMessage: prompt,
1335
+ skill: skill,
1336
+ ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1337
+ signal: turnAbort.signal,
1338
+ });
1339
+ }
1340
+ catch (err) {
1341
+ // v0.6 — graceful turn-error UX. The loop's try/catch already
1342
+ // persisted any partial assistant content to session.messages and
1343
+ // wrote the streamed prose to ~/.cspeach/streams/. Here we only
1344
+ // surface a clean 4–6 line diagnostic + write a full log so the
1345
+ // user can resume cleanly. See agent/turn-error-ux.ts for details.
1346
+ await renderTurnError({
1347
+ err,
1348
+ session,
1349
+ userInterrupted: interruptedByUser,
1350
+ print: (line) => console.error(line),
1351
+ });
1352
+ }
1353
+ finally {
1354
+ rl.removeListener('SIGINT', onSigint);
1355
+ turnInProgress = false;
1356
+ }
1357
+ // Status footer after each turn.
1358
+ // tool_result messages also have role:'user' but content is an array —
1359
+ // filter them out so we count actual user prompts, not tool rounds.
1360
+ const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
1361
+ const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
1362
+ const turnTokens = Math.max(0, lifetimeTokensAfter - lifetimeTokensBefore);
1363
+ // H4: do not write chalk status footer if Ink owns stdout — it corrupts
1364
+ // the frame. The Ink path renders its own <StatusRow /> above <Footer>.
1365
+ if (!shouldUseInk()) {
1366
+ printStatusFooter({
1367
+ sessionId: session.id,
1368
+ sapAlias: selectedAlias,
1369
+ skill: session.skill ?? '(none)',
1370
+ turnCount: userTurns,
1371
+ tokens: turnTokens,
1372
+ cachedTokens: session.usage.cache_read_input_tokens,
1373
+ elapsedMs: Date.now() - turnStart,
1374
+ });
1375
+ }
1376
+ // Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
1377
+ // JUST finished becomes `last_skill` for the next turn's classifier context.
1378
+ // `session.skill` itself is reassigned inside runTurn (agent/loop.ts) to the
1379
+ // CURRENT turn's skill, so we must snapshot `previousSkill` BEFORE runTurn.
1380
+ session.last_skill = previousSkill ?? null;
1381
+ session.last_object = extractedObject ?? previousObject ?? null;
1382
+ }
1383
+ }