@cspeach/cli 0.6.5 → 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/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 +1 -1
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
package/dist/renderer/tty.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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' },
|