@cspeach/cli 0.6.5 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +26 -0
  2. package/dist/agent/intent-system-prompt.js +46 -0
  3. package/dist/agent/loop.js +180 -18
  4. package/dist/agent/parallel-write-guard.js +71 -0
  5. package/dist/agent/repair-partial.js +88 -1
  6. package/dist/agent/retry-cap.js +121 -0
  7. package/dist/agent/summarise-via-provider.js +51 -0
  8. package/dist/approvals/jwt.js +33 -12
  9. package/dist/commands/auto-compact.js +94 -0
  10. package/dist/commands/compact.js +265 -0
  11. package/dist/commands/cost.js +56 -0
  12. package/dist/commands/help.js +21 -14
  13. package/dist/config/loader.js +39 -0
  14. package/dist/cost/cost-log.js +113 -0
  15. package/dist/cost/pricing.js +91 -0
  16. package/dist/one-shot.js +1 -1
  17. package/dist/renderer/markdown.js +7 -1
  18. package/dist/renderer/status-footer.js +37 -14
  19. package/dist/renderer/thinking-heartbeat.js +15 -3
  20. package/dist/renderer/tool-widget.js +6 -1
  21. package/dist/renderer/tty.js +27 -0
  22. package/dist/repl/cspeach-shell-detect.js +33 -0
  23. package/dist/repl/post-turn-status.js +68 -0
  24. package/dist/repl/slash-completer.js +2 -0
  25. package/dist/repl/slash-picker.js +2 -0
  26. package/dist/repl.js +371 -46
  27. package/dist/sap-errors/parse-adt-exception.js +149 -0
  28. package/dist/session/schema.js +2 -1
  29. package/dist/session/store.js +19 -0
  30. package/dist/tools/_filesystem-shared.js +9 -1
  31. package/dist/tools/ask-question.js +19 -0
  32. package/dist/tools/sap-read.js +56 -4
  33. package/dist/tools/sap-write.js +19 -0
  34. package/dist/tools/subagent/schedule_draft_create.js +137 -0
  35. package/dist/ui/alt-screen.js +120 -0
  36. package/dist/ui/app.js +47 -15
  37. package/dist/ui/ask-question-emitter.js +13 -0
  38. package/dist/ui/body.js +39 -23
  39. package/dist/ui/coaching-picker-classic.js +8 -1
  40. package/dist/ui/footer.js +10 -2
  41. package/dist/ui/header.js +19 -4
  42. package/dist/ui/sap-state-store.js +13 -1
  43. package/dist/ui/sidebar.js +5 -3
  44. package/dist/ui/status-emitter.js +19 -0
  45. package/dist/ui/status-row.js +30 -4
  46. package/dist/ui/widgets/ask-question-modal.js +132 -0
  47. package/dist/ui/widgets/coaching-picker.js +55 -8
  48. package/package.json +1 -1
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/schedule_create.js';
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
- function buildCoachingParams(result, prompt, preSelect) {
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 { prompt, options, preSelect };
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
- printHelp();
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
- 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'));
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
- console.log(chalk.green(`\n✓ UI mode set to ${chalk.bold(arg)}.`));
428
- console.log(chalk.dim('Relaunch cspeach to apply.\n'));
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
- console.log(chalk.red(`\n✗ Failed to save config: ${err.message}\n`));
623
+ emit(chalk.red(`\n✗ Failed to save config: ${err.message}`));
432
624
  }
433
625
  }
434
626
  else {
435
- console.log(chalk.red(`\nUnknown UI mode: ${arg}. Try auto / ink / classic.\n`));
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
- console.log(chalk.red(`\n${rr.message}\n`));
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
- console.log(chalk.dim(`↪ Rerouting "${preview}" → /${rr.skill}`));
640
+ chunkEmitter.emit('chunk', chalk.dim(`\n↪ Rerouting "${preview}" → /${rr.skill}\n`));
449
641
  if (rr.warning)
450
- console.log(chalk.dim(` (${rr.warning})`));
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
- console.log(chalk.dim('↩ fresh start — next prompt will be classified normally'));
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
- console.log(chalk.cyan(msg));
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
- console.log('Goodbye.\n');
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
- // 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
- }
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