@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
@@ -61,33 +61,40 @@ function wrapDescription(desc, width, maxLines) {
61
61
  * Cyan category headings, bold skill names, default-color descriptions.
62
62
  * Descriptions wrap to MAX_DESC_LINES lines max, aligned to the name column.
63
63
  */
64
- export function printHelp() {
64
+ /**
65
+ * Phase D5 (2026-05-17) — accepts an optional emit callback so Ink mode
66
+ * can route output through chunkEmitter instead of console.log (which
67
+ * corrupts the React frame). Default keeps the classic-mode behaviour.
68
+ */
69
+ export function printHelp(emit = (s) => console.log(s)) {
65
70
  const termWidth = process.stdout.columns ?? DEFAULT_TERM_WIDTH;
66
71
  const descWidth = Math.max(MIN_DESC_WIDTH, termWidth - NAME_COL_WIDTH);
67
72
  const continuationIndent = ' '.repeat(NAME_COL_WIDTH);
68
- console.log('');
73
+ emit('');
69
74
  for (const category of CATEGORY_ORDER) {
70
75
  const skills = SKILL_CATALOG.filter((s) => s.category === category);
71
76
  if (skills.length === 0)
72
77
  continue;
73
- console.log(chalk.cyan.bold(category));
78
+ emit(chalk.cyan.bold(category));
74
79
  for (const skill of skills) {
75
80
  const nameCol = ('/' + skill.name).padEnd(NAME_COL_PAD);
76
81
  const descLines = wrapDescription(skill.description, descWidth, MAX_DESC_LINES);
77
82
  const firstLine = descLines[0] ?? '';
78
- console.log(` ${chalk.bold(nameCol)} ${firstLine}`);
83
+ emit(` ${chalk.bold(nameCol)} ${firstLine}`);
79
84
  for (let j = 1; j < descLines.length; j++) {
80
- console.log(`${continuationIndent}${descLines[j]}`);
85
+ emit(`${continuationIndent}${descLines[j]}`);
81
86
  }
82
87
  }
83
- console.log('');
88
+ emit('');
84
89
  }
85
- console.log(chalk.cyan.bold('Shortcuts'));
86
- console.log(` ${chalk.bold('/help'.padEnd(NAME_COL_PAD))} This screen`);
87
- console.log(` ${chalk.bold('/skills'.padEnd(NAME_COL_PAD))} Same as /help`);
88
- console.log(` ${chalk.bold('/ui [auto|ink|classic]'.padEnd(NAME_COL_PAD))} View or change UI rendering mode`);
89
- console.log(` ${chalk.bold('/reroute <skill>'.padEnd(NAME_COL_PAD))} Re-run your last prompt with a different skill`);
90
- console.log(` ${chalk.bold('/new'.padEnd(NAME_COL_PAD))} End the current Q&A chain — next prompt is classified fresh`);
91
- console.log(` ${chalk.bold('/exit'.padEnd(NAME_COL_PAD))} Quit CSPeach`);
92
- console.log('');
90
+ emit(chalk.cyan.bold('Shortcuts'));
91
+ emit(` ${chalk.bold('/help'.padEnd(NAME_COL_PAD))} This screen`);
92
+ emit(` ${chalk.bold('/skills'.padEnd(NAME_COL_PAD))} Same as /help`);
93
+ emit(` ${chalk.bold('/ui [auto|ink|classic]'.padEnd(NAME_COL_PAD))} View or change UI rendering mode`);
94
+ emit(` ${chalk.bold('/reroute <skill>'.padEnd(NAME_COL_PAD))} Re-run your last prompt with a different skill`);
95
+ emit(` ${chalk.bold('/new'.padEnd(NAME_COL_PAD))} End the current Q&A chain — next prompt is classified fresh`);
96
+ emit(` ${chalk.bold('/cost'.padEnd(NAME_COL_PAD))} Show this session's API spend so far ($ + token breakdown)`);
97
+ emit(` ${chalk.bold('/compact'.padEnd(NAME_COL_PAD))} Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%`);
98
+ emit(` ${chalk.bold('/exit'.padEnd(NAME_COL_PAD))} Quit CSPeach`);
99
+ emit('');
93
100
  }
@@ -24,7 +24,42 @@ const DEFAULT_CONFIG = {
24
24
  write_mode: 'approval-gated',
25
25
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
26
26
  llm: { mode: 'managed' },
27
+ // Defaults tuned 2026-05-16 from session 0618a42d evidence — see CompactConfig doc.
28
+ compact: {
29
+ keep_recent_turns: 5,
30
+ auto_enabled: true,
31
+ auto_threshold_tokens: 1_000_000,
32
+ min_turns_between_auto: 5,
33
+ },
27
34
  };
35
+ /**
36
+ * Coerce a possibly-bad `compact` block from disk into a safe CompactConfig.
37
+ * - missing/undefined → defaults
38
+ * - wrong type → defaults
39
+ * - out-of-range numbers → clamped to sane bounds (keep_recent_turns 1..50,
40
+ * auto_threshold_tokens 10_000..10_000_000, min_turns_between_auto 1..100)
41
+ *
42
+ * Defensive because TOML hand-editing makes typos likely and we never want
43
+ * a config typo to lock the user out of the REPL.
44
+ */
45
+ function sanitiseCompact(raw) {
46
+ const d = DEFAULT_CONFIG.compact;
47
+ if (!raw || typeof raw !== 'object')
48
+ return { ...d };
49
+ const r = raw;
50
+ const intInRange = (v, fallback, lo, hi) => {
51
+ if (typeof v !== 'number' || !Number.isFinite(v))
52
+ return fallback;
53
+ const i = Math.floor(v);
54
+ return Math.min(hi, Math.max(lo, i));
55
+ };
56
+ return {
57
+ keep_recent_turns: intInRange(r.keep_recent_turns, d.keep_recent_turns, 1, 50),
58
+ auto_enabled: typeof r.auto_enabled === 'boolean' ? r.auto_enabled : d.auto_enabled,
59
+ auto_threshold_tokens: intInRange(r.auto_threshold_tokens, d.auto_threshold_tokens, 10_000, 10_000_000),
60
+ min_turns_between_auto: intInRange(r.min_turns_between_auto, d.min_turns_between_auto, 1, 100),
61
+ };
62
+ }
28
63
  export async function loadConfig() {
29
64
  try {
30
65
  const raw = await fs.readFile(configFile(), 'utf-8');
@@ -52,6 +87,10 @@ export async function loadConfig() {
52
87
  llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
53
88
  classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
54
89
  ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) },
90
+ // Deep-merge `compact` so a user config that only overrides one
91
+ // field (e.g. just `keep_recent_turns = 10`) still gets the four
92
+ // unspecified defaults — and a typo'd field falls through harmlessly.
93
+ compact: sanitiseCompact(parsed.compact),
55
94
  shell_exec: parsed.shell_exec === undefined ? undefined : { allow },
56
95
  };
57
96
  }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Per-turn cost log — Phase 1 (2026-05-16).
3
+ *
4
+ * Appends one JSONL line per turn to `~/.cspeach/sessions/<id>-cost.jsonl`
5
+ * with the model, all four token counters, and the computed $ cost. This
6
+ * is the persistent audit trail that lets us (a) show a session breakdown
7
+ * on demand via the `/cost` slash command and (b) analyse real usage
8
+ * offline (jq, pandas, whatever) to argue Phase 2 model-tiering from data.
9
+ *
10
+ * Failures are swallowed best-effort. A disk error must NOT crash a turn —
11
+ * the worst case is the user loses the per-turn line for that single turn,
12
+ * which is recoverable from session.usage at session-end if needed.
13
+ */
14
+ import fs from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { sessionsDir } from '../config/paths.js';
17
+ import { computeCost } from './pricing.js';
18
+ /**
19
+ * Path to a session's cost log file. Co-located with the session JSON so
20
+ * deletion cleans up both atomically.
21
+ */
22
+ export function costLogPath(sessionId) {
23
+ return path.join(sessionsDir(), `${sessionId}-cost.jsonl`);
24
+ }
25
+ /**
26
+ * Build a CostLogEntry from raw counters. Caller can either pass this to
27
+ * `appendCostLine` or accumulate in memory for the `/cost` command.
28
+ */
29
+ export function buildEntry(opts) {
30
+ return {
31
+ ts: new Date().toISOString(),
32
+ turn: opts.turn,
33
+ model: opts.model,
34
+ tokens: opts.tokens,
35
+ cost: computeCost(opts.model, opts.tokens),
36
+ duration_ms: opts.duration_ms,
37
+ };
38
+ }
39
+ /**
40
+ * Append a single line to the session's cost log. Best-effort:
41
+ * - Creates the parent dir if missing
42
+ * - Returns silently on any IO error (the log is observability, not a hard
43
+ * dependency of the turn)
44
+ */
45
+ export async function appendCostLine(sessionId, entry) {
46
+ try {
47
+ const filePath = costLogPath(sessionId);
48
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
49
+ const line = JSON.stringify(entry) + '\n';
50
+ await fs.appendFile(filePath, line, 'utf-8');
51
+ }
52
+ catch {
53
+ // Swallow — cost-log failures must not crash a turn. The status footer
54
+ // still shows the in-memory cost for the current turn; only the
55
+ // persisted history is lost.
56
+ }
57
+ }
58
+ /**
59
+ * Read all entries for a session. Used by the `/cost` slash command.
60
+ * Tolerates a missing file (returns []) and skips malformed lines.
61
+ */
62
+ export async function readCostLog(sessionId) {
63
+ try {
64
+ const filePath = costLogPath(sessionId);
65
+ const raw = await fs.readFile(filePath, 'utf-8');
66
+ const out = [];
67
+ for (const line of raw.split('\n')) {
68
+ const trimmed = line.trim();
69
+ if (!trimmed)
70
+ continue;
71
+ try {
72
+ out.push(JSON.parse(trimmed));
73
+ }
74
+ catch {
75
+ // Skip malformed line — partial write or manual edit, don't crash
76
+ }
77
+ }
78
+ return out;
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ }
84
+ export function summarise(entries) {
85
+ const totals = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
86
+ let total = 0;
87
+ const perModel = new Map();
88
+ for (const e of entries) {
89
+ total += e.cost;
90
+ totals.input += e.tokens.input;
91
+ totals.output += e.tokens.output;
92
+ totals.cacheRead += e.tokens.cacheRead;
93
+ totals.cacheCreate += e.tokens.cacheCreate;
94
+ const slot = perModel.get(e.model) ?? {
95
+ turns: 0,
96
+ cost: 0,
97
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 },
98
+ };
99
+ slot.turns += 1;
100
+ slot.cost += e.cost;
101
+ slot.tokens.input += e.tokens.input;
102
+ slot.tokens.output += e.tokens.output;
103
+ slot.tokens.cacheRead += e.tokens.cacheRead;
104
+ slot.tokens.cacheCreate += e.tokens.cacheCreate;
105
+ perModel.set(e.model, slot);
106
+ }
107
+ return {
108
+ totalCost: Math.round(total * 10000) / 10000,
109
+ totalTokens: totals,
110
+ turns: entries.length,
111
+ byModel: [...perModel.entries()].map(([model, v]) => ({ model, ...v })),
112
+ };
113
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Anthropic model pricing — USD per 1 million tokens.
3
+ *
4
+ * **Duplicate of `cspeach-proxy/src/anthropic/pricing.ts`** with one
5
+ * extension: this version also tracks `cache_creation_input_tokens` (the
6
+ * cache-write rate is 1.25× input per Anthropic's docs). The proxy version
7
+ * intentionally undercounts to stay conservative on customer bills; the
8
+ * CLI shows the cost to the developer who's spending it, so accuracy wins.
9
+ *
10
+ * **Sync responsibility**: when Anthropic updates pricing (~2x/year), update
11
+ * BOTH this file AND the proxy's version. Verify on the public pricing page:
12
+ * https://www.anthropic.com/pricing
13
+ *
14
+ * Last verified 2026-05-12. Same as proxy.
15
+ *
16
+ * Unknown models → returns null + records the model name to a process-level
17
+ * Set so we surface them as an obvious warning rather than silently zeroing.
18
+ */
19
+ const PRICING = {
20
+ // Claude Opus 4.x family — input 15, output 75, cache_read 1.50, cache_write 18.75
21
+ 'claude-opus-4-7': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
22
+ 'claude-opus-4-6': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
23
+ 'claude-opus-4-5': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
24
+ 'claude-opus-4': { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheCreatePer1M: 18.75 },
25
+ // Claude Sonnet 4.x family — input 3, output 15, cache_read 0.30, cache_write 3.75
26
+ 'claude-sonnet-4-6': { inputPer1M: 3.00, outputPer1M: 15.00, cacheReadPer1M: 0.30, cacheCreatePer1M: 3.75 },
27
+ 'claude-sonnet-4-5': { inputPer1M: 3.00, outputPer1M: 15.00, cacheReadPer1M: 0.30, cacheCreatePer1M: 3.75 },
28
+ 'claude-sonnet-4': { inputPer1M: 3.00, outputPer1M: 15.00, cacheReadPer1M: 0.30, cacheCreatePer1M: 3.75 },
29
+ // Claude Haiku 4.x family — input 1, output 5, cache_read 0.10, cache_write 1.25
30
+ 'claude-haiku-4-6-latest': { inputPer1M: 1.00, outputPer1M: 5.00, cacheReadPer1M: 0.10, cacheCreatePer1M: 1.25 },
31
+ 'claude-haiku-4-5-20251001': { inputPer1M: 1.00, outputPer1M: 5.00, cacheReadPer1M: 0.10, cacheCreatePer1M: 1.25 },
32
+ 'claude-haiku-4-5': { inputPer1M: 1.00, outputPer1M: 5.00, cacheReadPer1M: 0.10, cacheCreatePer1M: 1.25 },
33
+ 'claude-haiku-4': { inputPer1M: 1.00, outputPer1M: 5.00, cacheReadPer1M: 0.10, cacheCreatePer1M: 1.25 },
34
+ };
35
+ const seenUnknownModels = new Set();
36
+ /**
37
+ * Look up a per-1M-token rate for a model. Normalises to lowercase and
38
+ * strips Anthropic's optional `-YYYYMMDD` date suffix so e.g.
39
+ * `claude-haiku-4-5-20251001` matches `claude-haiku-4-5`.
40
+ *
41
+ * Returns null on unknown model and logs the name once per process.
42
+ * Callers should treat null as "skip cost calculation" — the worst case
43
+ * is the footer shows `$?` instead of a number.
44
+ */
45
+ export function getRate(model) {
46
+ const normalized = model.toLowerCase();
47
+ if (PRICING[normalized])
48
+ return PRICING[normalized];
49
+ const stripDate = normalized.replace(/-\d{8}$/, '');
50
+ if (PRICING[stripDate])
51
+ return PRICING[stripDate];
52
+ if (!seenUnknownModels.has(normalized)) {
53
+ seenUnknownModels.add(normalized);
54
+ // Best-effort warn; don't crash if the console is unavailable.
55
+ try {
56
+ console.warn(JSON.stringify({ msg: 'pricing_unknown_model', model: normalized }));
57
+ }
58
+ catch { /* noop */ }
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Compute the USD cost of a token bundle at the given model's rate.
64
+ *
65
+ * Returns 0 on unknown model (after `getRate` logged the warning).
66
+ *
67
+ * Rounded to 4 decimals (1/100 of a cent) — sufficient precision for the
68
+ * sub-cent per-tool-call line items we may surface later, avoids float
69
+ * drift in subsequent sums.
70
+ */
71
+ export function computeCost(model, t) {
72
+ const r = getRate(model);
73
+ if (!r)
74
+ return 0;
75
+ const cost = (t.input * r.inputPer1M +
76
+ t.output * r.outputPer1M +
77
+ t.cacheRead * r.cacheReadPer1M +
78
+ t.cacheCreate * r.cacheCreatePer1M) / 1_000_000;
79
+ return Math.round(cost * 10000) / 10000;
80
+ }
81
+ /**
82
+ * Format a USD number for display in the REPL footer. Sub-cent values
83
+ * render as `<$0.01` so the user doesn't see a meaningless `$0.0023`.
84
+ */
85
+ export function formatCost(usd) {
86
+ if (usd < 0.01)
87
+ return '<$0.01';
88
+ if (usd < 10)
89
+ return `$${usd.toFixed(2)}`;
90
+ return `$${usd.toFixed(1)}`;
91
+ }
package/dist/one-shot.js CHANGED
@@ -32,7 +32,7 @@ import './tools/project/convention_get.js';
32
32
  import './tools/subagent/_background-shared.js';
33
33
  import './tools/subagent/background_run.js';
34
34
  import './tools/subagent/monitor_emit.js';
35
- import './tools/subagent/schedule_create.js';
35
+ import './tools/subagent/schedule_draft_create.js';
36
36
  import './tools/subagent/agent_run.js';
37
37
  export async function runOneShot(prompt, opts) {
38
38
  const cfg = await loadConfig();
@@ -16,6 +16,7 @@ import { marked } from 'marked';
16
16
  import { markedTerminal } from 'marked-terminal';
17
17
  import Table from 'cli-table3';
18
18
  import { applySeverityToCell } from './severity.js';
19
+ import { getEffectiveBodyWidth } from './tty.js';
19
20
  const PEACH = '#FFB88C';
20
21
  // marked-terminal uses these sentinels internally. We parse them back out to
21
22
  // rebuild a width-capped cli-table3 in our table renderer override below.
@@ -176,7 +177,12 @@ marked.use({
176
177
  }
177
178
  }
178
179
  }
179
- const termWidth = process.stdout.columns || 120;
180
+ // Phase A #2 (2026-05-16) — was `process.stdout.columns || 120` which
181
+ // over-allocated in Ink mode: the Body sits next to a 30-col Sidebar,
182
+ // so tables sized for full terminal width wrapped inside Body and
183
+ // bled into the Sidebar visually. getEffectiveBodyWidth() returns
184
+ // `cols − sidebar − border` under Ink and the full width under classic.
185
+ const termWidth = getEffectiveBodyWidth();
180
186
  const colWidths = computeColWidths(naturalWidths, minWidths, termWidth);
181
187
  // Decide whether row separators are needed. When every row fits on one
182
188
  // visual line we suppress them (avoids stairstep on small tables, see
@@ -1,6 +1,14 @@
1
1
  import chalk from 'chalk';
2
+ import { formatCost } from '../cost/pricing.js';
2
3
  const RULE_WIDTH = 72;
3
4
  const RULE = chalk.dim('─'.repeat(RULE_WIDTH));
5
+ /**
6
+ * Phase 2 (2026-05-16) — when a session crosses this much non-cached input,
7
+ * we nudge the user to /compact. Threshold is "feel right" — tuned to fire
8
+ * after ~10-15 substantial turns of build work but not on light Q&A days.
9
+ * Re-evaluate after a week of real data.
10
+ */
11
+ export const LARGE_SESSION_THRESHOLD = 100_000;
4
12
  /**
5
13
  * Prints a status footer after each turn ends.
6
14
  *
@@ -14,37 +22,52 @@ const RULE = chalk.dim('─'.repeat(RULE_WIDTH));
14
22
  * the session-lifetime `session.usage`. `cachedTokens` remains lifetime
15
23
  * (cache reads are rare per-turn; the session running count is more useful).
16
24
  *
17
- * M9 — Dollar display removed in v0.2: accurate pricing requires splitting
18
- * input / output / cached-read / cached-write tokens against four different
19
- * per-MTok prices, which the footer does not have access to post-turn.
20
- * A blended `$30/M * totalTokens` formula is wrong by up to 6× on cache-heavy
21
- * turns (cached reads are $0.30/M, not $30/M) and misleads rather than
22
- * informs. v0.3 adds proper usage breakdown via
23
- * `session.tokens_by_category` and restores a provably correct cost line.
25
+ * Phase 1 cost tracking (2026-05-16): the M9 caveat is resolved. The caller
26
+ * now snapshots all four token categories pre-turn (input, output, cache_read,
27
+ * cache_create) and computes the delta-driven $ cost via cost/pricing.ts at
28
+ * the correct per-MTok rates per category. `costThisTurn` and `costSession`
29
+ * are optional on the params for back-compat with callers that haven't been
30
+ * updated yet — when both are absent the footer renders the v0.2 cost-free
31
+ * format unchanged.
24
32
  *
25
33
  * v0.3 transition: this chalk status-footer-after-turn is replaced (not
26
34
  * augmented) by the Ink persistent footer region in Plan 4c.
27
35
  */
28
- export function printStatusFooter(params) {
29
- const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs } = params;
36
+ /**
37
+ * Format the status footer as a single string (with embedded newlines).
38
+ * Pulled out from `printStatusFooter` so the Ink path can route the
39
+ * exact same line through chunkEmitter → Body's <Static> instead of
40
+ * direct stdout (which would corrupt the live Ink frame).
41
+ */
42
+ export function formatStatusFooter(params) {
43
+ const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens } = params;
30
44
  const shortSession = sessionId.slice(0, 8);
31
45
  const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
32
46
  const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
33
- // H1 — "this turn" makes it explicit the token count is per-turn, not
34
- // session-lifetime. The REPL passes the pre/post-runTurn delta.
35
47
  const tokStr = tokens.toLocaleString() + ' tok this turn';
36
48
  const cachedStr = cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
37
49
  const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
50
+ const costTurnStr = costThisTurn != null ? formatCost(costThisTurn) : '';
51
+ const costSessStr = costSession != null ? `session: ${formatCost(costSession)}` : '';
38
52
  const parts = [
39
53
  chalk.dim(`session ${shortSession}`),
40
54
  chalk.dim(sapAlias),
41
55
  chalk.dim(skillDisplay),
42
56
  chalk.dim(turns),
43
57
  chalk.dim(tokStr),
58
+ ...(costTurnStr ? [chalk.dim(costTurnStr)] : []),
44
59
  ...(cachedStr ? [chalk.dim(cachedStr)] : []),
60
+ ...(costSessStr ? [chalk.dim(costSessStr)] : []),
45
61
  ...(elapsed ? [chalk.dim(elapsed)] : []),
46
62
  ];
47
- console.log('\n' + RULE);
48
- console.log(' ' + parts.join(chalk.dim(' │ ')));
49
- console.log(RULE + '\n');
63
+ const lines = ['', RULE, ' ' + parts.join(chalk.dim(' │ '))];
64
+ if (sessionInputTokens != null && sessionInputTokens >= LARGE_SESSION_THRESHOLD) {
65
+ lines.push(' ' + chalk.yellow(`⚠ session getting large (${sessionInputTokens.toLocaleString()} input tokens)`
66
+ + ` — type /compact to summarise older turns, or /exit + cspeach (fresh) for a new topic`));
67
+ }
68
+ lines.push(RULE, '');
69
+ return lines.join('\n');
70
+ }
71
+ export function printStatusFooter(params) {
72
+ process.stdout.write(formatStatusFooter(params));
50
73
  }
@@ -39,19 +39,31 @@ const THRESHOLDS_MS = [
39
39
  { at: 180_000, label: 'thinking… (3m — Ctrl+C is recommended; the stream may have stalled)' },
40
40
  { at: 300_000, label: 'thinking… (5m — Ctrl+C and try again)' },
41
41
  ];
42
- export function startThinkingHeartbeat() {
42
+ export function startThinkingHeartbeat(opts) {
43
43
  const startedAt = Date.now();
44
44
  let nextIdx = 0;
45
45
  let stopped = false;
46
+ const emitLine = (line) => {
47
+ if (opts?.chunkEmitter) {
48
+ // Phase A #4 — route through Ink's chunk pipeline. The Body
49
+ // component's reducer treats this like any other model chunk and
50
+ // appends it as a node, so the heartbeat lives inside the React
51
+ // render tree and survives subsequent renders.
52
+ opts.chunkEmitter.emit('chunk', line);
53
+ }
54
+ else {
55
+ // Classic mode — direct stdout, plain newline-terminated line.
56
+ process.stdout.write(line);
57
+ }
58
+ };
46
59
  const tick = () => {
47
60
  if (stopped)
48
61
  return;
49
62
  const elapsed = Date.now() - startedAt;
50
63
  while (nextIdx < THRESHOLDS_MS.length && THRESHOLDS_MS[nextIdx].at <= elapsed) {
51
64
  const t = THRESHOLDS_MS[nextIdx];
52
- // Plain newline-terminated line — survives any terminal mode.
53
65
  // Indent by 2 spaces to align with tool-call rows.
54
- process.stdout.write(chalk.dim(` ${t.label}\n`));
66
+ emitLine(chalk.dim(` ${t.label}\n`));
55
67
  nextIdx++;
56
68
  }
57
69
  };
@@ -22,7 +22,12 @@ import { PEACH } from './banners.js';
22
22
  */
23
23
  const peach = (s) => chalk.hex(PEACH)(s);
24
24
  const ARG_SUMMARY_MAX = 64;
25
- const RESULT_SUMMARY_MAX = 96;
25
+ // 2026-05-15 (bug 1): widened from 96 → 240 so a parsed ADT exception
26
+ // message ("CTS_WBO_API/047: Request S4HK903388 is not a local request"
27
+ // — ~55 chars) plus a trailing ` · 1.2s` timing fits comfortably on one
28
+ // terminal line without losing the human-readable cause. Anything past
29
+ // 240 chars genuinely is XML/JSON noise and is fine to clip.
30
+ const RESULT_SUMMARY_MAX = 240;
26
31
  /** Braille spinner — 8 frames at 80ms each gives a smooth rotation that
27
32
  * reads as "still working" without being distracting. */
28
33
  const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
@@ -64,3 +64,30 @@ export function isWindowsLegacyTerminal() {
64
64
  export function shouldUseUnicodeBoxChars() {
65
65
  return !isWindowsLegacyTerminal();
66
66
  }
67
+ /**
68
+ * Extra cols reserved for the Body region's border (2 for left/right
69
+ * borders + 2 for safety against single-char rounding in Ink's flex
70
+ * layout). Subtracted from terminal width by getEffectiveBodyWidth.
71
+ */
72
+ const BORDER_OVERHEAD = 4;
73
+ /**
74
+ * Returns the effective horizontal width available to the Body region.
75
+ *
76
+ * In Ink mode the Body sits to the LEFT of a fixed-width Sidebar, so the
77
+ * effective width is `cols - SIDEBAR_WIDTH - BORDER_OVERHEAD`. In classic
78
+ * mode the full terminal width is available. Markdown table column
79
+ * allocation should call this, not `process.stdout.columns`, otherwise
80
+ * wide tables wrap inside the Body and bleed visually into the Sidebar.
81
+ *
82
+ * Phase A #2 (2026-05-16) — closes B1.
83
+ */
84
+ export function getEffectiveBodyWidth() {
85
+ const cols = process.stdout.columns || 120;
86
+ if (!shouldUseInk())
87
+ return cols;
88
+ // Phase D (2026-05-16): Sidebar dropped — Body now spans the full
89
+ // terminal width minus the border overhead. Was `cols - SIDEBAR_WIDTH
90
+ // - BORDER_OVERHEAD` (= cols - 34), now `cols - BORDER_OVERHEAD`
91
+ // (= cols - 4). Wide tables get ~30 more cols to breathe.
92
+ return Math.max(40, cols - BORDER_OVERHEAD);
93
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Detect a `cspeach ...` shell command typed *inside* the REPL.
3
+ *
4
+ * Bug 11b (2026-05-15): during a live session, the user accidentally typed
5
+ * `cspeach --resume 17c04ce8` at the REPL prompt instead of in their
6
+ * terminal. The line was routed as a prompt to the skill classifier — at
7
+ * best wasted a turn, at worst poisoned the session further. The user
8
+ * had to /exit and re-run the command in the host shell anyway.
9
+ *
10
+ * Detection rule:
11
+ * - Line (already trimmed) starts with the literal token `cspeach`
12
+ * followed by either end-of-line OR whitespace.
13
+ * - The word `cspeach` appearing inside a sentence ("How does cspeach
14
+ * handle X?") does NOT match — only a leading token.
15
+ *
16
+ * The caller should print a friendly nudge and short-circuit the prompt.
17
+ */
18
+ export function looksLikeCspeachShellCommand(line) {
19
+ if (!line)
20
+ return false;
21
+ // Match `cspeach` at the very start, followed by end-of-string OR a
22
+ // single space/tab. Critically NOT a newline — a multi-line paste whose
23
+ // first line happens to start with `cspeach` (e.g. a doc snippet or
24
+ // chat log) should pass through to the model, not get silently swallowed.
25
+ // Case-sensitive — the CLI binary is lowercase by convention and a
26
+ // capitalised "Cspeach" in a sentence should pass through normally.
27
+ return /^cspeach($|[ \t])/.test(line);
28
+ }
29
+ /**
30
+ * Human-facing message printed when the detector fires. Exported so tests
31
+ * can assert on the exact wording.
32
+ */
33
+ export const CSPEACH_SHELL_NUDGE = 'That looks like a shell command for your terminal. Inside CSPeach, type `/exit` first, then run that in your shell.';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Post-turn status push — shared helper for classic + Ink REPL paths.
3
+ *
4
+ * Phase D6 (2026-05-17) rewrite: with the alt-screen / Header removed
5
+ * in favour of inline + <Static>, BOTH modes now use the same chalk
6
+ * status line. The difference is just the sink:
7
+ *
8
+ * Classic mode → printStatusFooter() writes to stdout directly
9
+ * Ink mode → chunkEmitter.emit('chunk', formatted) — Body's
10
+ * <Static> commits the line to scrollback
11
+ *
12
+ * The sapStateStore push that used to drive a Header / Sidebar is
13
+ * gone (no consumer). All status visibility now lives in the printed
14
+ * status line that accompanies each turn — matches CC's pattern.
15
+ */
16
+ import { computeCost } from '../cost/pricing.js';
17
+ import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
18
+ import { shouldUseInk } from '../renderer/tty.js';
19
+ export function snapshotTurnStart(session) {
20
+ return {
21
+ input: session.usage.input_tokens,
22
+ output: session.usage.output_tokens,
23
+ cacheRead: session.usage.cache_read_input_tokens,
24
+ cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
25
+ startedAt: Date.now(),
26
+ };
27
+ }
28
+ export function pushPostTurnStatus(params) {
29
+ const { session, selectedAlias, snapshot, chunkEmitter } = params;
30
+ const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
31
+ const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
32
+ const lifetimeTokensBefore = snapshot.input + snapshot.output;
33
+ const turnTokens = Math.max(0, lifetimeTokensAfter - lifetimeTokensBefore);
34
+ const turnCostTokens = {
35
+ input: session.usage.input_tokens - snapshot.input,
36
+ output: session.usage.output_tokens - snapshot.output,
37
+ cacheRead: session.usage.cache_read_input_tokens - snapshot.cacheRead,
38
+ cacheCreate: (session.usage.cache_creation_input_tokens ?? 0) - snapshot.cacheCreate,
39
+ };
40
+ const turnCost = computeCost(session.model, turnCostTokens);
41
+ const sessionCost = computeCost(session.model, {
42
+ input: session.usage.input_tokens,
43
+ output: session.usage.output_tokens,
44
+ cacheRead: session.usage.cache_read_input_tokens,
45
+ cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
46
+ });
47
+ const statusPayload = {
48
+ sessionId: session.id,
49
+ sapAlias: selectedAlias,
50
+ skill: session.skill ?? '(none)',
51
+ turnCount: userTurns,
52
+ tokens: turnTokens,
53
+ cachedTokens: session.usage.cache_read_input_tokens,
54
+ elapsedMs: Date.now() - snapshot.startedAt,
55
+ costThisTurn: turnCost,
56
+ costSession: sessionCost,
57
+ sessionInputTokens: session.usage.input_tokens,
58
+ };
59
+ if (shouldUseInk() && chunkEmitter) {
60
+ // Push through chunkEmitter so the line lands in Body's <Static>
61
+ // scrollback alongside the conversation, instead of corrupting
62
+ // the live Ink frame.
63
+ chunkEmitter.emit('chunk', formatStatusFooter(statusPayload));
64
+ }
65
+ else {
66
+ printStatusFooter(statusPayload);
67
+ }
68
+ }
@@ -20,6 +20,8 @@ import { SKILL_CATALOG } from '../skill-catalog.js';
20
20
  */
21
21
  const BUILTIN_COMMANDS = [
22
22
  '/cancel',
23
+ '/compact',
24
+ '/cost',
23
25
  '/exit',
24
26
  '/quit',
25
27
  '/help',
@@ -24,6 +24,8 @@ import { inquirerTheme } from './inquirer-theme.js';
24
24
  import { SKILL_CATALOG } from '../skill-catalog.js';
25
25
  const BUILTIN_ENTRIES = [
26
26
  { name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
27
+ { name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
28
+ { name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
27
29
  { name: '/exit', description: 'Exit CSPeach' },
28
30
  { name: '/quit', description: 'Exit CSPeach (alias)' },
29
31
  { name: '/help', description: 'Show all skills and commands' },