@cspeach/cli 0.6.4 → 0.7.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/dist/agent/intent-system-prompt.js +46 -0
- package/dist/agent/loop.js +180 -18
- package/dist/agent/parallel-write-guard.js +71 -0
- package/dist/agent/repair-partial.js +88 -1
- package/dist/agent/retry-cap.js +121 -0
- package/dist/agent/summarise-via-provider.js +51 -0
- package/dist/approvals/jwt.js +33 -12
- package/dist/commands/auto-compact.js +94 -0
- package/dist/commands/compact.js +265 -0
- package/dist/commands/cost.js +56 -0
- package/dist/commands/help.js +21 -14
- package/dist/config/loader.js +39 -0
- package/dist/cost/cost-log.js +113 -0
- package/dist/cost/pricing.js +91 -0
- package/dist/index.js +0 -0
- package/dist/one-shot.js +1 -1
- package/dist/renderer/markdown.js +7 -1
- package/dist/renderer/status-footer.js +37 -14
- package/dist/renderer/thinking-heartbeat.js +15 -3
- package/dist/renderer/tool-widget.js +6 -1
- package/dist/renderer/tty.js +27 -0
- package/dist/repl/cspeach-shell-detect.js +33 -0
- package/dist/repl/post-turn-status.js +68 -0
- package/dist/repl/slash-completer.js +2 -0
- package/dist/repl/slash-picker.js +2 -0
- package/dist/repl.js +371 -46
- package/dist/sap-errors/parse-adt-exception.js +149 -0
- package/dist/session/schema.js +2 -1
- package/dist/session/store.js +19 -0
- package/dist/tools/_filesystem-shared.js +9 -1
- package/dist/tools/ask-question.js +19 -0
- package/dist/tools/sap-read.js +56 -4
- package/dist/tools/sap-write.js +19 -0
- package/dist/tools/subagent/schedule_draft_create.js +137 -0
- package/dist/ui/alt-screen.js +120 -0
- package/dist/ui/app.js +39 -15
- package/dist/ui/ask-question-emitter.js +13 -0
- package/dist/ui/body.js +39 -23
- package/dist/ui/coaching-picker-classic.js +8 -1
- package/dist/ui/footer.js +6 -1
- package/dist/ui/header.js +19 -4
- package/dist/ui/sap-state-store.js +13 -1
- package/dist/ui/sidebar.js +5 -3
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-row.js +30 -4
- package/dist/ui/widgets/ask-question-modal.js +132 -0
- package/dist/ui/widgets/coaching-picker.js +55 -8
- package/package.json +86 -82
- package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
- package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
- package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
- package/dist/agent/__tests__/project-context-injection.test.js +0 -161
- package/dist/agent/__tests__/repair-partial.test.js +0 -79
- package/dist/agent/__tests__/retry-key.test.js +0 -77
- package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
- package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
- package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
- package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
- package/dist/agent/__tests__/turn-stream.test.js +0 -100
- package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
- package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
- package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
- package/dist/agent/providers/__tests__/factory.test.js +0 -33
- package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
- package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
- package/dist/approvals/__tests__/advisory-render.test.js +0 -54
- package/dist/auth/__tests__/auth-file.test.js +0 -42
- package/dist/auth/__tests__/me.test.js +0 -43
- package/dist/classifier/__tests__/client.test.js +0 -70
- package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
- package/dist/commands/__tests__/login.test.js +0 -152
- package/dist/commands/__tests__/logout.test.js +0 -39
- package/dist/commands/__tests__/project-context-impact.test.js +0 -173
- package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
- package/dist/commands/__tests__/whoami.test.js +0 -91
- package/dist/config/__tests__/llm-config.test.js +0 -28
- package/dist/config/__tests__/shell-exec-config.test.js +0 -76
- package/dist/config/__tests__/write-mode.test.js +0 -36
- package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
- package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
- package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
- package/dist/project-context/__tests__/conventions.test.js +0 -221
- package/dist/project-context/__tests__/detect.test.js +0 -177
- package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
- package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
- package/dist/project-context/__tests__/domain-cap.test.js +0 -168
- package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
- package/dist/project-context/__tests__/git.test.js +0 -131
- package/dist/project-context/__tests__/index-files.test.js +0 -195
- package/dist/project-context/__tests__/index.test.js +0 -133
- package/dist/project-context/__tests__/render.test.js +0 -322
- package/dist/project-context/__tests__/types.test.js +0 -73
- package/dist/projects/__tests__/build.test.js +0 -52
- package/dist/projects/__tests__/canonicalize.test.js +0 -47
- package/dist/projects/__tests__/email-template.test.js +0 -71
- package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
- package/dist/projects/__tests__/extract-cca.test.js +0 -141
- package/dist/projects/__tests__/extract-design.test.js +0 -62
- package/dist/projects/__tests__/extract-estimate.test.js +0 -58
- package/dist/projects/__tests__/extract-modernize.test.js +0 -127
- package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
- package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
- package/dist/projects/__tests__/filename.test.js +0 -32
- package/dist/projects/__tests__/promote-command.test.js +0 -170
- package/dist/projects/__tests__/promote.test.js +0 -181
- package/dist/projects/__tests__/save-command.test.js +0 -216
- package/dist/projects/__tests__/save.test.js +0 -41
- package/dist/projects/__tests__/status.test.js +0 -208
- package/dist/projects/__tests__/types.test.js +0 -41
- package/dist/projects/__tests__/validate.test.js +0 -165
- package/dist/projects/__tests__/workspace.test.js +0 -337
- package/dist/repl/__tests__/current-transport.test.js +0 -43
- package/dist/repl/__tests__/diff-display.test.js +0 -59
- package/dist/repl/__tests__/file-picker.test.js +0 -97
- package/dist/repl/__tests__/rule8-detector.test.js +0 -114
- package/dist/repl/__tests__/safety-confirm.test.js +0 -130
- package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
- package/dist/sap/__tests__/system-info.test.js +0 -344
- package/dist/session/__tests__/store-discovery.test.js +0 -114
- package/dist/session/__tests__/time-ago.test.js +0 -53
- package/dist/skills/__tests__/canonical.test.js +0 -20
- package/dist/skills/__tests__/manifest-client.test.js +0 -88
- package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
- package/dist/skills/__tests__/source-managed.test.js +0 -60
- package/dist/tools/__tests__/_background-shared.test.js +0 -69
- package/dist/tools/__tests__/_command-shared.test.js +0 -59
- package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
- package/dist/tools/__tests__/_flag.test.js +0 -66
- package/dist/tools/__tests__/_helpers.js +0 -17
- package/dist/tools/__tests__/_web-shared.test.js +0 -143
- package/dist/tools/__tests__/agent_run.test.js +0 -166
- package/dist/tools/__tests__/approval-advisory.test.js +0 -66
- package/dist/tools/__tests__/background_run.test.js +0 -113
- package/dist/tools/__tests__/convention_get.test.js +0 -53
- package/dist/tools/__tests__/file-edit.test.js +0 -72
- package/dist/tools/__tests__/file-read.test.js +0 -81
- package/dist/tools/__tests__/file-write.test.js +0 -68
- package/dist/tools/__tests__/glob.test.js +0 -128
- package/dist/tools/__tests__/grep.test.js +0 -71
- package/dist/tools/__tests__/monitor_emit.test.js +0 -96
- package/dist/tools/__tests__/playbook_get.test.js +0 -109
- package/dist/tools/__tests__/project_context_get.test.js +0 -71
- package/dist/tools/__tests__/registry-category.test.js +0 -60
- package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
- package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
- package/dist/tools/__tests__/schedule_create.test.js +0 -85
- package/dist/tools/__tests__/shell_exec.test.js +0 -135
- package/dist/tools/__tests__/transport-safety.test.js +0 -106
- package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
- package/dist/tools/__tests__/web_fetch.test.js +0 -329
- package/dist/tools/__tests__/web_search.test.js +0 -215
- package/dist/tools/__tests__/write-mode.test.js +0 -32
- package/dist/ui/__tests__/login-banner.test.js +0 -69
package/dist/repl.js
CHANGED
|
@@ -21,6 +21,7 @@ import { getConnection } from './sap/connection-manager.js';
|
|
|
21
21
|
import { AdtClient } from '@cspeach/sap-client';
|
|
22
22
|
import { makeSessionId, saveSession } from './session/store.js';
|
|
23
23
|
import { newSession } from './session/schema.js';
|
|
24
|
+
import { computeCost, formatCost } from './cost/pricing.js';
|
|
24
25
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
25
26
|
import { runTurn } from './agent/loop.js';
|
|
26
27
|
import { renderTurnError } from './agent/turn-error-ux.js';
|
|
@@ -28,6 +29,7 @@ import { classifyPrompt, parseShortcut } from './router/classifier.js';
|
|
|
28
29
|
import { decideRouting } from './router/routing-decision.js';
|
|
29
30
|
import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
|
|
30
31
|
import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
|
|
32
|
+
import { globalStore as sapStateStore } from './ui/sap-state-store.js';
|
|
31
33
|
import { PICK_CANCELLED } from './ui/skill-picker.js';
|
|
32
34
|
import { registerReadline, withInquirer } from './repl/inquirer-guard.js';
|
|
33
35
|
import { maybePromptWorkspaceMigration } from './projects/migration.js';
|
|
@@ -57,7 +59,6 @@ function prefillReadline(rl, text) {
|
|
|
57
59
|
}
|
|
58
60
|
import { BracketedPasteDecoder, enableBracketedPaste, disableBracketedPaste, } from './repl/bracketed-paste.js';
|
|
59
61
|
import { printHelp } from './commands/help.js';
|
|
60
|
-
import { printStatusFooter } from './renderer/status-footer.js';
|
|
61
62
|
import { shouldUseInk, setRenderingOverride } from './renderer/tty.js';
|
|
62
63
|
import { saveConfig } from './config/loader.js';
|
|
63
64
|
import { EventEmitter } from 'node:events';
|
|
@@ -65,6 +66,8 @@ import { parseAndValidateReroute } from './commands/reroute.js';
|
|
|
65
66
|
import { SKILL_CATALOG } from './skill-catalog.js';
|
|
66
67
|
import { replPreviewHook } from './repl/update-method-preview-hook.js';
|
|
67
68
|
import { getCurrentTransport, handleTransportCommand } from './repl/current-transport.js';
|
|
69
|
+
import { snapshotTurnStart, pushPostTurnStatus } from './repl/post-turn-status.js';
|
|
70
|
+
import { looksLikeCspeachShellCommand, CSPEACH_SHELL_NUDGE } from './repl/cspeach-shell-detect.js';
|
|
68
71
|
// Side-effect imports so tool registrations happen before listTools() is called.
|
|
69
72
|
import './tools/sap-read.js';
|
|
70
73
|
import './tools/sap-write.js';
|
|
@@ -87,7 +90,7 @@ import './tools/project/convention_get.js';
|
|
|
87
90
|
import './tools/subagent/_background-shared.js';
|
|
88
91
|
import './tools/subagent/background_run.js';
|
|
89
92
|
import './tools/subagent/monitor_emit.js';
|
|
90
|
-
import './tools/subagent/
|
|
93
|
+
import './tools/subagent/schedule_draft_create.js';
|
|
91
94
|
import './tools/subagent/agent_run.js';
|
|
92
95
|
const PEACH = '#FFCBA4';
|
|
93
96
|
const HISTORY_FILE = path.join(os.homedir(), '.cspeach', 'history');
|
|
@@ -137,6 +140,32 @@ const ACK_PHRASES = new Set([
|
|
|
137
140
|
'decline', 'denied', 'reject', 'rejected',
|
|
138
141
|
'skip', 'next',
|
|
139
142
|
]);
|
|
143
|
+
/** Conversational acknowledgements that mean "I'm done with the previous
|
|
144
|
+
* turn, no further work needed." Distinct from ACK_PHRASES (which
|
|
145
|
+
* largely mean "yes/no to a question"). For these we don't classify,
|
|
146
|
+
* don't call any model — just print a friendly ack and return. The user
|
|
147
|
+
* isn't asking us to do more work; they're closing the prior thread.
|
|
148
|
+
*
|
|
149
|
+
* 2026-05-17 — Laeeq hit "thank you" being classified and run through
|
|
150
|
+
* /abap-explain, producing another huge code explanation. THIS is the
|
|
151
|
+
* fix for that. */
|
|
152
|
+
const CONVERSATIONAL_ACKS = new Set([
|
|
153
|
+
'thanks', 'thank you', 'thank u', 'thx', 'tx', 'ty', 'ta',
|
|
154
|
+
'cool', 'nice', 'great', 'awesome', 'perfect',
|
|
155
|
+
'got it', 'gotit', 'understood',
|
|
156
|
+
'cheers',
|
|
157
|
+
// Common ackowledgements that follow a long answer.
|
|
158
|
+
'makes sense',
|
|
159
|
+
]);
|
|
160
|
+
/**
|
|
161
|
+
* True when `trimmed` is one of CONVERSATIONAL_ACKS. We treat these as
|
|
162
|
+
* "user closes the previous thread, no further work" — print a friendly
|
|
163
|
+
* acknowledgement and return without classifying or calling any model.
|
|
164
|
+
*/
|
|
165
|
+
function isConversationalAck(trimmed) {
|
|
166
|
+
const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
167
|
+
return CONVERSATIONAL_ACKS.has(normalized);
|
|
168
|
+
}
|
|
140
169
|
/**
|
|
141
170
|
* Returns true when `trimmed` is a short acknowledgement-style follow-up
|
|
142
171
|
* AND there is a previous skill to continue. Treats both bare acks and
|
|
@@ -169,6 +198,41 @@ function isAckContinuation(trimmed, sessionSkill) {
|
|
|
169
198
|
* the "nonsense like 'yes' → /abap-explain at 35%" failure that ACK_PHRASES
|
|
170
199
|
* was introduced to prevent.
|
|
171
200
|
*/
|
|
201
|
+
/**
|
|
202
|
+
* Print the goodbye + session-saved message on /exit. Used by both
|
|
203
|
+
* classic and Ink paths. In Ink the caller MUST invoke this AFTER
|
|
204
|
+
* leaveAltScreen() so the message lands in the user's restored shell
|
|
205
|
+
* scrollback (not the alt-screen which is about to disappear).
|
|
206
|
+
*
|
|
207
|
+
* 2026-05-17 — enriched to show session summary (turn count, last
|
|
208
|
+
* skill, total cost) so the user has something to look back on after
|
|
209
|
+
* the alt-screen takedown wipes the conversation. Without this the
|
|
210
|
+
* shell shows only the startup banner + a bare "session saved" line,
|
|
211
|
+
* which feels like everything they just did vanished.
|
|
212
|
+
*/
|
|
213
|
+
function printGoodbye(session) {
|
|
214
|
+
const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
|
|
215
|
+
const sessionCost = computeCost(session.model, {
|
|
216
|
+
input: session.usage.input_tokens ?? 0,
|
|
217
|
+
output: session.usage.output_tokens ?? 0,
|
|
218
|
+
cacheRead: session.usage.cache_read_input_tokens ?? 0,
|
|
219
|
+
cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
|
|
220
|
+
});
|
|
221
|
+
const totalTokens = (session.usage.input_tokens ?? 0) +
|
|
222
|
+
(session.usage.output_tokens ?? 0) +
|
|
223
|
+
(session.usage.cache_read_input_tokens ?? 0) +
|
|
224
|
+
(session.usage.cache_creation_input_tokens ?? 0);
|
|
225
|
+
console.log('');
|
|
226
|
+
console.log(chalk.bold(`Session ${session.id}`));
|
|
227
|
+
console.log(chalk.dim(` turns: ${userTurns}`));
|
|
228
|
+
console.log(chalk.dim(` last skill: ${session.skill ?? '(none)'}`));
|
|
229
|
+
console.log(chalk.dim(` tokens: ${totalTokens.toLocaleString()} (input+output+cache)`));
|
|
230
|
+
console.log(chalk.dim(` spend: ${formatCost(sessionCost)}`));
|
|
231
|
+
console.log('');
|
|
232
|
+
console.log(chalk.cyan(`Resume with: cspeach --resume ${session.id}`));
|
|
233
|
+
console.log(chalk.dim(`Full log: ~/.cspeach/streams/${session.id}.md`));
|
|
234
|
+
console.log('');
|
|
235
|
+
}
|
|
172
236
|
function isBareAck(trimmed) {
|
|
173
237
|
const normalized = trimmed.toLowerCase().trim().replace(/[.!?,]+$/, '');
|
|
174
238
|
return ACK_PHRASES.has(normalized);
|
|
@@ -178,23 +242,52 @@ function isBareAck(trimmed) {
|
|
|
178
242
|
* Picks top-3 candidates (top-1 + top-2 alternates), looks up description
|
|
179
243
|
* and whenToUse from the catalog per entry. Falls back to entry.description
|
|
180
244
|
* if whenToUse is missing (defensive — drift-guard test should prevent this).
|
|
245
|
+
*
|
|
246
|
+
* 2026-05-16 (screenshot 72): always pads to at least MIN_OPTIONS so the
|
|
247
|
+
* user has something to arrow to.
|
|
248
|
+
* 2026-05-17 (continuation UX): the just-used skill (lastSkill) is
|
|
249
|
+
* always included in candidates (de-duped, no double-render). Picker
|
|
250
|
+
* then defaults its cursor to that option via preferSkill, so a
|
|
251
|
+
* follow-up question to a recent skill is a 1-Enter-press continuation
|
|
252
|
+
* instead of having to arrow to the right item.
|
|
181
253
|
*/
|
|
182
|
-
|
|
254
|
+
const PICKER_FALLBACK_SKILLS = [
|
|
255
|
+
'abap-explain', 'abap-review', 'abap-document', 'abap-rap', 'abap-radar',
|
|
256
|
+
];
|
|
257
|
+
const MIN_PICKER_OPTIONS = 3;
|
|
258
|
+
function buildCoachingParams(result, prompt, preSelect, lastSkill) {
|
|
183
259
|
const candidates = [result.skill, ...result.alternates].slice(0, 3);
|
|
260
|
+
// Include the just-used skill if not already present — gives the user
|
|
261
|
+
// a one-keypress "continue with same skill" path. Inserted AFTER the
|
|
262
|
+
// classifier's top-1 so the classifier's best guess still leads
|
|
263
|
+
// visually; the cursor will default to lastSkill via preferSkill below.
|
|
264
|
+
if (lastSkill && !candidates.includes(lastSkill)) {
|
|
265
|
+
candidates.splice(1, 0, lastSkill);
|
|
266
|
+
}
|
|
267
|
+
// Pad with fallback skills if total still under the minimum.
|
|
268
|
+
if (candidates.length < MIN_PICKER_OPTIONS) {
|
|
269
|
+
for (const fallback of PICKER_FALLBACK_SKILLS) {
|
|
270
|
+
if (candidates.length >= MIN_PICKER_OPTIONS)
|
|
271
|
+
break;
|
|
272
|
+
if (!candidates.includes(fallback))
|
|
273
|
+
candidates.push(fallback);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
184
276
|
const options = candidates.map((skill) => {
|
|
185
277
|
const entry = SKILL_CATALOG.find((e) => e.name === skill);
|
|
186
278
|
return {
|
|
187
279
|
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
280
|
confidence: skill === result.skill ? result.confidence : 0,
|
|
193
281
|
whatItDoes: entry?.description ?? 'no description',
|
|
194
282
|
whenToPick: entry?.whenToUse ?? entry?.description ?? 'no description',
|
|
195
283
|
};
|
|
196
284
|
});
|
|
197
|
-
return {
|
|
285
|
+
return {
|
|
286
|
+
prompt,
|
|
287
|
+
options,
|
|
288
|
+
preSelect,
|
|
289
|
+
preferSkill: lastSkill ?? undefined,
|
|
290
|
+
};
|
|
198
291
|
}
|
|
199
292
|
export async function runRepl(opts) {
|
|
200
293
|
printStartupBanner();
|
|
@@ -306,6 +399,14 @@ export async function runRepl(opts) {
|
|
|
306
399
|
}
|
|
307
400
|
const conn = await getConnection(selectedAlias, cfg.sap[selectedAlias]);
|
|
308
401
|
const adt = new AdtClient(conn);
|
|
402
|
+
// Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
|
|
403
|
+
// Push system + user the moment we know the alias. Before this commit
|
|
404
|
+
// the sidebar showed "no system / user: —" forever because the store
|
|
405
|
+
// had .update() methods but no callers.
|
|
406
|
+
sapStateStore.update({
|
|
407
|
+
system: `${selectedAlias} (${cfg.sap[selectedAlias].host})`,
|
|
408
|
+
user: cfg.sap[selectedAlias].username,
|
|
409
|
+
});
|
|
309
410
|
// v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
|
|
310
411
|
// preserves messages[], usage, last_skill, awaitingSkillAnswer, and the
|
|
311
412
|
// turnInterrupted flag from the prior run. Skipping newSession() avoids
|
|
@@ -361,22 +462,111 @@ export async function runRepl(opts) {
|
|
|
361
462
|
const { App } = await import('./ui/app.js');
|
|
362
463
|
const React = (await import('react')).default;
|
|
363
464
|
const chunkEmitter = new EventEmitter();
|
|
465
|
+
// Phase D6 (2026-05-17) — INLINE Ink mode (no alt-screen).
|
|
466
|
+
//
|
|
467
|
+
// After Laeeq pointed out CC achieves great scrolling without any
|
|
468
|
+
// alt-screen, we reverted the alt-screen attempt. Ink renders to
|
|
469
|
+
// the normal terminal stream; Body uses <Static> to commit each
|
|
470
|
+
// node to scrollback. The terminal's native scroll (mouse wheel,
|
|
471
|
+
// PgUp, search) just works because we're not stealing the screen.
|
|
472
|
+
//
|
|
473
|
+
// Things this delete-line cleanup removes (vs the alt-screen path):
|
|
474
|
+
// - enterAltScreen / leaveAltScreen calls
|
|
475
|
+
// - hideCursor interval workaround
|
|
476
|
+
// - bracketed-paste enable (now handled the classic-mode way below)
|
|
477
|
+
// - PgUp/PgDn/Ctrl+B scroll state in App + Body
|
|
478
|
+
// - termHeight + SIGWINCH resize tracking
|
|
479
|
+
// - exitOnCtrlC: false render option (default behaviour is fine)
|
|
480
|
+
// Hide the terminal's native cursor while Ink owns the frame. Ink's
|
|
481
|
+
// dynamic frame ends with a newline, parking the real cursor on the
|
|
482
|
+
// line BELOW the Footer ("booger cursor"). ink-text-input's
|
|
483
|
+
// showCursor=true draws a block character at the actual typing
|
|
484
|
+
// position. Hiding the native cursor leaves just that block — clear
|
|
485
|
+
// visual indicator without the distracting phantom blink below.
|
|
486
|
+
// Restored on /exit via the SHOW_CURSOR write below. Belt-and-
|
|
487
|
+
// braces: also restore on process exit / uncaught exception so a
|
|
488
|
+
// crash doesn't leave the user's shell with no visible cursor.
|
|
489
|
+
process.stdout.write('\x1b[?25l');
|
|
490
|
+
const restoreCursor = () => { process.stdout.write('\x1b[?25h'); };
|
|
491
|
+
process.once('exit', restoreCursor);
|
|
492
|
+
process.once('uncaughtException', (err) => {
|
|
493
|
+
restoreCursor();
|
|
494
|
+
process.stderr.write(`\nUncaught: ${err.stack ?? String(err)}\n`);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
});
|
|
364
497
|
let done = false;
|
|
365
498
|
let resolveDone = null;
|
|
366
499
|
const donePromise = new Promise((r) => { resolveDone = r; });
|
|
500
|
+
// Phase D5 (2026-05-17) — per-turn AbortController for SIGINT.
|
|
501
|
+
// Ink puts stdin in raw mode so the OS SIGINT never fires; we catch
|
|
502
|
+
// Ctrl+C via App.useInput → onInterrupt callback below. That callback
|
|
503
|
+
// either aborts the in-flight turn (if one's running) or exits the
|
|
504
|
+
// app cleanly (if at idle). Mirrors classic mode's turn-scoped abort
|
|
505
|
+
// semantics — first Ctrl+C aborts, second exits.
|
|
506
|
+
//
|
|
507
|
+
// currentTurnAbort: when set, a turn is in progress and we abort
|
|
508
|
+
// it. When null, we're at idle and should exit on Ctrl+C.
|
|
509
|
+
let currentTurnAbort = null;
|
|
510
|
+
let interruptCount = 0;
|
|
367
511
|
const handleSubmit = async (line) => {
|
|
368
512
|
const trimmed = line.trim();
|
|
369
513
|
if (!trimmed)
|
|
370
514
|
return;
|
|
371
515
|
appendHistory(trimmed);
|
|
516
|
+
// Phase A** (2026-05-16) — echo the user's prompt into Body so it
|
|
517
|
+
// appears in the conversation history. Plus a dim divider above
|
|
518
|
+
// the echo so consecutive turns have a clear visual break (closes
|
|
519
|
+
// Laeeq's screenshot-70 observation: previous turn's prose and
|
|
520
|
+
// new turn's content touched with no separator and looked like
|
|
521
|
+
// garbled overlap).
|
|
522
|
+
if (trimmed !== '/exit' && trimmed !== '/quit') {
|
|
523
|
+
chunkEmitter.emit('chunk', chalk.dim('\n──────────────────────────────────\n') +
|
|
524
|
+
chalk.cyan(`› ${trimmed}\n`));
|
|
525
|
+
}
|
|
372
526
|
if (trimmed === '/exit' || trimmed === '/quit') {
|
|
373
527
|
done = true;
|
|
374
528
|
if (resolveDone)
|
|
375
529
|
resolveDone();
|
|
376
530
|
return;
|
|
377
531
|
}
|
|
532
|
+
// Bug 11b (2026-05-15) — user typed `cspeach ...` at the REPL prompt
|
|
533
|
+
// by mistake. Catch it before any classifier or skill routing fires.
|
|
534
|
+
if (looksLikeCspeachShellCommand(trimmed)) {
|
|
535
|
+
chunkEmitter.emit('chunk', chalk.yellow(`\n${CSPEACH_SHELL_NUDGE}\n`));
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
378
538
|
if (trimmed === '/help' || trimmed === '/skills') {
|
|
379
|
-
|
|
539
|
+
// Phase D5 — route through chunkEmitter so help lands in Body,
|
|
540
|
+
// not above the Ink frame (which would scroll off + corrupt).
|
|
541
|
+
printHelp((line) => chunkEmitter.emit('chunk', line + '\n'));
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// Phase 1 cost tracking (2026-05-16) — /cost prints session $ summary
|
|
545
|
+
// from the JSONL log written each turn. Read-only, no network.
|
|
546
|
+
if (trimmed === '/cost') {
|
|
547
|
+
const { runCostCommand } = await import('./commands/cost.js');
|
|
548
|
+
await runCostCommand(session.id);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
// Phase 2 conversation hygiene (2026-05-16) — /compact summarises
|
|
552
|
+
// older turns via Haiku so subsequent turns ingest ~5k tokens of
|
|
553
|
+
// context instead of 500k+. Saves \$3-8 per follow-up turn.
|
|
554
|
+
if (trimmed === '/compact') {
|
|
555
|
+
const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser, COMPACTION_MODEL } = await import('./commands/compact.js');
|
|
556
|
+
const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
|
|
557
|
+
const { loadConfig } = await import('./config/loader.js');
|
|
558
|
+
const compactCfg = (await loadConfig()).compact;
|
|
559
|
+
const summarise = async (toSummarise) => {
|
|
560
|
+
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise), { model: COMPACTION_MODEL, maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
561
|
+
};
|
|
562
|
+
try {
|
|
563
|
+
chunkEmitter.emit('chunk', chalk.dim('\nCompacting older turns into a summary...\n'));
|
|
564
|
+
const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
|
|
565
|
+
chunkEmitter.emit('chunk', formatCompactResult(result, compactCfg.keep_recent_turns));
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
chunkEmitter.emit('chunk', chalk.red(`\n/compact failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
569
|
+
}
|
|
380
570
|
return;
|
|
381
571
|
}
|
|
382
572
|
// /files — open interactive picker for .cspeach.json artefacts in
|
|
@@ -406,33 +596,35 @@ export async function runRepl(opts) {
|
|
|
406
596
|
return;
|
|
407
597
|
}
|
|
408
598
|
// /ui — view or change rendering mode. Ink-mode branch mirrors non-Ink.
|
|
599
|
+
// Phase D5 (2026-05-17): all console.log → chunkEmitter so output
|
|
600
|
+
// lands in Body, not above the React frame.
|
|
409
601
|
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
602
|
if (!cfg.ui)
|
|
413
603
|
cfg.ui = { rendering: 'auto' };
|
|
414
604
|
const arg = trimmed.slice(3).trim().toLowerCase();
|
|
605
|
+
const emit = (s) => chunkEmitter.emit('chunk', s + '\n');
|
|
415
606
|
if (arg === '') {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
607
|
+
emit('');
|
|
608
|
+
emit(`Current UI mode: ${chalk.cyan.bold(cfg.ui.rendering)} ` +
|
|
609
|
+
`(effective now: ${chalk.yellow(shouldUseInk() ? 'ink' : 'classic')})`);
|
|
610
|
+
emit(chalk.dim(' /ui classic — chalk REPL (default)'));
|
|
611
|
+
emit(chalk.dim(' /ui ink — Ink frame (opt-in)'));
|
|
612
|
+
emit(chalk.dim(' /ui auto — choose automatically'));
|
|
613
|
+
emit(chalk.dim('Change takes effect after /exit + relaunch.'));
|
|
422
614
|
}
|
|
423
615
|
else if (arg === 'auto' || arg === 'ink' || arg === 'classic') {
|
|
424
616
|
cfg.ui.rendering = arg;
|
|
425
617
|
try {
|
|
426
618
|
await saveConfig(cfg);
|
|
427
|
-
|
|
428
|
-
|
|
619
|
+
emit(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
|
|
620
|
+
emit(chalk.dim('Relaunch cspeach to apply.'));
|
|
429
621
|
}
|
|
430
622
|
catch (err) {
|
|
431
|
-
|
|
623
|
+
emit(chalk.red(`\n✗ Failed to save config: ${err.message}`));
|
|
432
624
|
}
|
|
433
625
|
}
|
|
434
626
|
else {
|
|
435
|
-
|
|
627
|
+
emit(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.`));
|
|
436
628
|
}
|
|
437
629
|
return;
|
|
438
630
|
}
|
|
@@ -441,13 +633,13 @@ export async function runRepl(opts) {
|
|
|
441
633
|
session.awaitingSkillAnswer = false;
|
|
442
634
|
const rr = parseAndValidateReroute(trimmed, session.lastUserPrompt, SKILL_CATALOG);
|
|
443
635
|
if (rr.kind === 'error') {
|
|
444
|
-
|
|
636
|
+
chunkEmitter.emit('chunk', chalk.red(`\n${rr.message}\n`));
|
|
445
637
|
return;
|
|
446
638
|
}
|
|
447
639
|
const preview = rr.prompt.length > 60 ? rr.prompt.slice(0, 57) + '...' : rr.prompt;
|
|
448
|
-
|
|
640
|
+
chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
|
|
449
641
|
if (rr.warning)
|
|
450
|
-
|
|
642
|
+
chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
|
|
451
643
|
await runTurn({
|
|
452
644
|
provider,
|
|
453
645
|
userMessage: rr.prompt,
|
|
@@ -462,14 +654,14 @@ export async function runRepl(opts) {
|
|
|
462
654
|
// previous skill's Q&A chain.
|
|
463
655
|
if (trimmed === '/new' || trimmed === '/reset') {
|
|
464
656
|
session.awaitingSkillAnswer = false;
|
|
465
|
-
|
|
657
|
+
chunkEmitter.emit('chunk', chalk.dim('\n↩ fresh start — next prompt will be classified normally\n'));
|
|
466
658
|
return;
|
|
467
659
|
}
|
|
468
660
|
// /transport — session current-transport state for Rule 9 (transport isolation)
|
|
469
661
|
if (trimmed === '/transport' || trimmed.startsWith('/transport ')) {
|
|
470
662
|
const arg = trimmed === '/transport' ? '' : trimmed.slice('/transport '.length);
|
|
471
663
|
const msg = handleTransportCommand(arg);
|
|
472
|
-
|
|
664
|
+
chunkEmitter.emit('chunk', chalk.cyan(`\n${msg}\n`));
|
|
473
665
|
return;
|
|
474
666
|
}
|
|
475
667
|
const parsed = parseShortcut(trimmed);
|
|
@@ -495,6 +687,16 @@ export async function runRepl(opts) {
|
|
|
495
687
|
// Use session.skill (the skill of the IMMEDIATELY PREVIOUS turn) —
|
|
496
688
|
// NOT session.last_skill which holds the skill from TWO turns ago
|
|
497
689
|
// and is used as classifier context.
|
|
690
|
+
// 2026-05-17 — conversational ack ("thank you", "thanks", "cool",
|
|
691
|
+
// "got it", etc.) means the user is closing the prior thread, not
|
|
692
|
+
// asking for more work. Print a friendly response and return
|
|
693
|
+
// BEFORE the classifier/skill machinery. Saves the user from
|
|
694
|
+
// accidentally running another expensive turn on a thank-you.
|
|
695
|
+
if (isConversationalAck(trimmed)) {
|
|
696
|
+
chunkEmitter.emit('chunk', chalk.cyan('You\'re welcome. ') +
|
|
697
|
+
chalk.dim('Type a new question, /new to reset, or /exit to quit.\n'));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
498
700
|
if ((session.awaitingSkillAnswer || isAckContinuation(trimmed, session.skill)) && session.skill) {
|
|
499
701
|
chunkEmitter.emit('chunk', chalk.dim(`↪ Continuing /${session.skill}\n`));
|
|
500
702
|
skill = session.skill;
|
|
@@ -514,19 +716,24 @@ export async function runRepl(opts) {
|
|
|
514
716
|
last_object: extractedObject ?? session.last_object ?? null,
|
|
515
717
|
});
|
|
516
718
|
const decision = decideRouting(c);
|
|
719
|
+
// 2026-05-17 — pass the just-used skill so the picker can
|
|
720
|
+
// default cursor to it (one-Enter "continue" UX for follow-up
|
|
721
|
+
// questions). session.skill = immediately-previous turn's
|
|
722
|
+
// skill, NOT session.last_skill which is from TWO turns ago.
|
|
723
|
+
const continueSkill = session.skill ?? null;
|
|
517
724
|
let pickedResult;
|
|
518
725
|
if (cfg.classifier.safe_mode) {
|
|
519
|
-
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false));
|
|
726
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(c, trimmed, false, continueSkill));
|
|
520
727
|
}
|
|
521
728
|
else if (decision.kind === 'silent') {
|
|
522
729
|
chunkEmitter.emit('chunk', chalk.dim(`┄ Routed to /${decision.skill}\n`));
|
|
523
730
|
pickedResult = decision.skill;
|
|
524
731
|
}
|
|
525
732
|
else if (decision.kind === 'coach') {
|
|
526
|
-
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true));
|
|
733
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, true, continueSkill));
|
|
527
734
|
}
|
|
528
735
|
else {
|
|
529
|
-
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false));
|
|
736
|
+
pickedResult = await coachingPickerEmitter.request(buildCoachingParams(decision.result, trimmed, false, continueSkill));
|
|
530
737
|
}
|
|
531
738
|
if (pickedResult === PICK_CANCELLED) {
|
|
532
739
|
chunkEmitter.emit('chunk', chalk.dim('↩ cancelled — type a new prompt\n'));
|
|
@@ -572,6 +779,19 @@ export async function runRepl(opts) {
|
|
|
572
779
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
573
780
|
return;
|
|
574
781
|
}
|
|
782
|
+
// Phase A** wiring fix (2026-05-16) — snapshot pre-turn counters so
|
|
783
|
+
// pushPostTurnStatus can compute per-turn deltas. The classic-mode
|
|
784
|
+
// turn loop has its own equivalent below; both paths now call the
|
|
785
|
+
// shared post-turn helper so StatusRow + Sidebar update in BOTH
|
|
786
|
+
// modes. (Pre-fix this whole block didn't exist in the Ink path —
|
|
787
|
+
// sidebar stayed frozen at "0 tok / no turns yet" forever.)
|
|
788
|
+
const turnSnapshot = snapshotTurnStart(session);
|
|
789
|
+
// Phase D5 (2026-05-17) — install per-turn abort. onInterrupt
|
|
790
|
+
// (Ctrl+C handler) calls currentTurnAbort.abort() while this is
|
|
791
|
+
// non-null, which tears the in-flight provider stream down via
|
|
792
|
+
// the signal we thread into runTurn.
|
|
793
|
+
currentTurnAbort = new AbortController();
|
|
794
|
+
interruptCount = 0;
|
|
575
795
|
try {
|
|
576
796
|
await runTurn({
|
|
577
797
|
provider,
|
|
@@ -579,6 +799,7 @@ export async function runRepl(opts) {
|
|
|
579
799
|
skill: skill,
|
|
580
800
|
ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
|
|
581
801
|
chunkEmitter,
|
|
802
|
+
signal: currentTurnAbort.signal,
|
|
582
803
|
});
|
|
583
804
|
}
|
|
584
805
|
catch (err) {
|
|
@@ -589,6 +810,10 @@ export async function runRepl(opts) {
|
|
|
589
810
|
body: err.message,
|
|
590
811
|
});
|
|
591
812
|
}
|
|
813
|
+
finally {
|
|
814
|
+
currentTurnAbort = null;
|
|
815
|
+
}
|
|
816
|
+
pushPostTurnStatus({ session, selectedAlias, snapshot: turnSnapshot, chunkEmitter });
|
|
592
817
|
session.last_skill = previousSkill ?? null;
|
|
593
818
|
session.last_object = extractedObject ?? previousObject ?? null;
|
|
594
819
|
// 2026-05-08: post-turn auto-dispatch. The dispatch_skill tool may
|
|
@@ -606,6 +831,34 @@ export async function runRepl(opts) {
|
|
|
606
831
|
setImmediate(() => { void handleSubmit(dispatched); });
|
|
607
832
|
}
|
|
608
833
|
};
|
|
834
|
+
// Phase D5 (2026-05-17) — Ctrl+C handler routed through Ink.
|
|
835
|
+
// First press during turn: abort the turn (preserves partial work).
|
|
836
|
+
// Second press during the SAME turn: hard-exit (escape hatch in case
|
|
837
|
+
// the abort doesn't take effect for some reason).
|
|
838
|
+
// Press at idle: clean exit (printGoodbye + leaveAltScreen).
|
|
839
|
+
const onInterrupt = () => {
|
|
840
|
+
if (currentTurnAbort) {
|
|
841
|
+
interruptCount++;
|
|
842
|
+
if (interruptCount === 1) {
|
|
843
|
+
chunkEmitter.emit('chunk', chalk.yellow('\n⚠ Interrupting turn — saving partial work...\n'));
|
|
844
|
+
currentTurnAbort.abort();
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
// Second Ctrl+C during the same turn — give up and exit.
|
|
848
|
+
chunkEmitter.emit('chunk', chalk.red('\n✗ Force-exit on second Ctrl+C.\n'));
|
|
849
|
+
unmount();
|
|
850
|
+
process.stdout.write('\x1b[?25h'); // restore cursor
|
|
851
|
+
printGoodbye(session);
|
|
852
|
+
process.exit(130); // 128 + SIGINT
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
// Idle — clean exit, same path as /exit.
|
|
857
|
+
done = true;
|
|
858
|
+
if (resolveDone)
|
|
859
|
+
resolveDone();
|
|
860
|
+
}
|
|
861
|
+
};
|
|
609
862
|
const { unmount } = render(React.createElement(App, {
|
|
610
863
|
chunkEmitter,
|
|
611
864
|
onSubmit: (line) => { void handleSubmit(line); },
|
|
@@ -613,9 +866,23 @@ export async function runRepl(opts) {
|
|
|
613
866
|
onHistoryTrigger: () => { },
|
|
614
867
|
getToolCalls: () => session.toolCalls,
|
|
615
868
|
writeMode: cfg.write_mode,
|
|
616
|
-
|
|
869
|
+
onInterrupt,
|
|
870
|
+
}), {
|
|
871
|
+
// Critical: without this Ink calls process.exit() on Ctrl+C and
|
|
872
|
+
// kills the CLI before our onInterrupt handler can decide what
|
|
873
|
+
// to do. We want to make that decision ourselves (abort turn
|
|
874
|
+
// vs exit at idle).
|
|
875
|
+
exitOnCtrlC: false,
|
|
876
|
+
});
|
|
617
877
|
await donePromise;
|
|
618
878
|
unmount();
|
|
879
|
+
// Restore the terminal cursor we hid on Ink mount.
|
|
880
|
+
process.stdout.write('\x1b[?25h');
|
|
881
|
+
// Print session summary + resume hint after Ink unmounts so the
|
|
882
|
+
// line lands in the user's shell scrollback alongside the session
|
|
883
|
+
// history. In inline mode the conversation IS in scrollback (no
|
|
884
|
+
// alt-screen erased it), so this is just a friendly bookend.
|
|
885
|
+
printGoodbye(session);
|
|
619
886
|
return 0;
|
|
620
887
|
}
|
|
621
888
|
// --- readline setup (replaces inquirer input()) ---
|
|
@@ -957,15 +1224,52 @@ export async function runRepl(opts) {
|
|
|
957
1224
|
continue;
|
|
958
1225
|
}
|
|
959
1226
|
if (trimmed === '/exit' || trimmed === '/quit') {
|
|
960
|
-
|
|
1227
|
+
printGoodbye(session);
|
|
961
1228
|
rl.close();
|
|
962
1229
|
return 0;
|
|
963
1230
|
}
|
|
1231
|
+
// Bug 11b (2026-05-15) — user typed `cspeach ...` at the REPL prompt
|
|
1232
|
+
// by mistake (e.g. `cspeach --resume <id>` while still inside CSPeach).
|
|
1233
|
+
// Catch it before any classifier or skill routing fires so we don't
|
|
1234
|
+
// waste a turn and don't pollute the session with the stray line.
|
|
1235
|
+
if (looksLikeCspeachShellCommand(trimmed)) {
|
|
1236
|
+
console.log(chalk.yellow(`\n${CSPEACH_SHELL_NUDGE}\n`));
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
964
1239
|
// /help and /skills show the skill catalog flat-list.
|
|
965
1240
|
if (trimmed === '/help' || trimmed === '/skills') {
|
|
966
1241
|
printHelp();
|
|
967
1242
|
continue;
|
|
968
1243
|
}
|
|
1244
|
+
// Phase 1 cost tracking (2026-05-16) — classic-mode /cost.
|
|
1245
|
+
// Mirrors the Ink-mode handler above. /cost prints session $ summary
|
|
1246
|
+
// from the JSONL log written each turn. Read-only, no network.
|
|
1247
|
+
if (trimmed === '/cost') {
|
|
1248
|
+
const { runCostCommand } = await import('./commands/cost.js');
|
|
1249
|
+
await runCostCommand(session.id);
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
// Phase 2 conversation hygiene (2026-05-16) — classic-mode /compact.
|
|
1253
|
+
// Summarises older turns via Haiku 4.5 so subsequent turns ingest ~5k
|
|
1254
|
+
// tokens of context instead of 500k+. Backs up session pre-mutation.
|
|
1255
|
+
if (trimmed === '/compact') {
|
|
1256
|
+
const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser, COMPACTION_MODEL } = await import('./commands/compact.js');
|
|
1257
|
+
const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
|
|
1258
|
+
const { loadConfig } = await import('./config/loader.js');
|
|
1259
|
+
const compactCfg = (await loadConfig()).compact;
|
|
1260
|
+
const summarise = async (toSummarise) => {
|
|
1261
|
+
return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise), { model: COMPACTION_MODEL, maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact' } });
|
|
1262
|
+
};
|
|
1263
|
+
try {
|
|
1264
|
+
console.log(chalk.dim('\nCompacting older turns into a summary...'));
|
|
1265
|
+
const result = await runCompact({ session, summarise, keepRecentTurns: compactCfg.keep_recent_turns });
|
|
1266
|
+
console.log(formatCompactResult(result, compactCfg.keep_recent_turns));
|
|
1267
|
+
}
|
|
1268
|
+
catch (err) {
|
|
1269
|
+
console.log(chalk.red(`\n/compact failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
1270
|
+
}
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
969
1273
|
// /files — open interactive picker for .cspeach.json artefacts in
|
|
970
1274
|
// the workspace folder. After picking a file and an action, pre-fill
|
|
971
1275
|
// the synthesized slash command into the NEXT prompt via pendingPrefill
|
|
@@ -1208,6 +1512,12 @@ export async function runRepl(opts) {
|
|
|
1208
1512
|
// Use session.skill (the skill of the IMMEDIATELY PREVIOUS turn) —
|
|
1209
1513
|
// NOT session.last_skill which holds the skill from TWO turns ago
|
|
1210
1514
|
// and is used as classifier context.
|
|
1515
|
+
// 2026-05-17 — conversational ack short-circuit (matches Ink path).
|
|
1516
|
+
if (isConversationalAck(trimmed)) {
|
|
1517
|
+
console.log(chalk.cyan('You\'re welcome. ') +
|
|
1518
|
+
chalk.dim('Type a new question, /new to reset, or /exit to quit.'));
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1211
1521
|
if ((session.awaitingSkillAnswer || isAckContinuation(trimmed, session.skill)) && session.skill) {
|
|
1212
1522
|
console.log(chalk.dim(`↪ Continuing /${session.skill}`));
|
|
1213
1523
|
skill = session.skill;
|
|
@@ -1227,6 +1537,10 @@ export async function runRepl(opts) {
|
|
|
1227
1537
|
last_object: extractedObject ?? session.last_object ?? null,
|
|
1228
1538
|
});
|
|
1229
1539
|
const decision = decideRouting(c);
|
|
1540
|
+
// 2026-05-17 — pass the just-used skill so the picker can
|
|
1541
|
+
// default cursor to it. session.skill = immediately-previous
|
|
1542
|
+
// turn's skill, NOT session.last_skill (TWO turns ago).
|
|
1543
|
+
const continueSkillClassic = session.skill ?? null;
|
|
1230
1544
|
let pickedResult;
|
|
1231
1545
|
// Coaching picker path — inquirer takes over stdin. Use the shared
|
|
1232
1546
|
// withInquirer guard so the suppress-close window stays active
|
|
@@ -1238,7 +1552,7 @@ export async function runRepl(opts) {
|
|
|
1238
1552
|
};
|
|
1239
1553
|
if (cfg.classifier.safe_mode) {
|
|
1240
1554
|
// Safe mode overrides the decision — always open the full picker.
|
|
1241
|
-
pickedResult = await runPicker(buildCoachingParams(c, trimmed, false));
|
|
1555
|
+
pickedResult = await runPicker(buildCoachingParams(c, trimmed, false, continueSkillClassic));
|
|
1242
1556
|
}
|
|
1243
1557
|
else if (decision.kind === 'silent') {
|
|
1244
1558
|
console.log(chalk.dim(`┄ Routed to /${decision.skill}`));
|
|
@@ -1246,7 +1560,7 @@ export async function runRepl(opts) {
|
|
|
1246
1560
|
}
|
|
1247
1561
|
else {
|
|
1248
1562
|
const preSelect = decision.kind === 'coach';
|
|
1249
|
-
pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect));
|
|
1563
|
+
pickedResult = await runPicker(buildCoachingParams(decision.result, trimmed, preSelect, continueSkillClassic));
|
|
1250
1564
|
}
|
|
1251
1565
|
if (pickedResult === PICK_CANCELLED) {
|
|
1252
1566
|
console.log(chalk.dim('↩ cancelled — type a new prompt'));
|
|
@@ -1299,6 +1613,15 @@ export async function runRepl(opts) {
|
|
|
1299
1613
|
// over Option B (changing label to "lifetime") because users care about
|
|
1300
1614
|
// what the turn they just ran cost them, not the session running total.
|
|
1301
1615
|
const lifetimeTokensBefore = session.usage.input_tokens + session.usage.output_tokens;
|
|
1616
|
+
// Phase 1 cost tracking (2026-05-16) — snapshot all four counters so
|
|
1617
|
+
// the footer can compute per-turn $ (loop.ts also writes a JSONL line,
|
|
1618
|
+
// but the footer needs the value right here for immediate display).
|
|
1619
|
+
const turnStartCostSnapshot = {
|
|
1620
|
+
input: session.usage.input_tokens,
|
|
1621
|
+
output: session.usage.output_tokens,
|
|
1622
|
+
cacheRead: session.usage.cache_read_input_tokens,
|
|
1623
|
+
cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
|
|
1624
|
+
};
|
|
1302
1625
|
// v0.6 — per-turn SIGINT hard-interrupt + AbortController.
|
|
1303
1626
|
//
|
|
1304
1627
|
// Critical: Node readline with `terminal: true` (which we use) puts
|
|
@@ -1360,19 +1683,21 @@ export async function runRepl(opts) {
|
|
|
1360
1683
|
const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
|
|
1361
1684
|
const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
|
|
1362
1685
|
const turnTokens = Math.max(0, lifetimeTokensAfter - lifetimeTokensBefore);
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1686
|
+
// Phase A** (2026-05-16) — both classic + Ink paths now share the
|
|
1687
|
+
// same post-turn helper. See src/repl/post-turn-status.ts. Earlier
|
|
1688
|
+
// this block lived inline here and the Ink path's separate runTurn
|
|
1689
|
+
// call site never reached it → sidebar froze forever under Ink.
|
|
1690
|
+
pushPostTurnStatus({
|
|
1691
|
+
session,
|
|
1692
|
+
selectedAlias,
|
|
1693
|
+
snapshot: {
|
|
1694
|
+
input: turnStartCostSnapshot.input,
|
|
1695
|
+
output: turnStartCostSnapshot.output,
|
|
1696
|
+
cacheRead: turnStartCostSnapshot.cacheRead,
|
|
1697
|
+
cacheCreate: turnStartCostSnapshot.cacheCreate,
|
|
1698
|
+
startedAt: turnStart,
|
|
1699
|
+
},
|
|
1700
|
+
});
|
|
1376
1701
|
// Post-turn roll-forward (Step 7c.2 / 7c.5) — the skill from the turn that
|
|
1377
1702
|
// JUST finished becomes `last_skill` for the next turn's classifier context.
|
|
1378
1703
|
// `session.skill` itself is reassigned inside runTurn (agent/loop.ts) to the
|