@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.
- package/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- 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
|
+
}
|