@cspeach/cli 1.0.0 → 1.1.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.
- package/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +71 -78
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- package/package.json +4 -1
|
@@ -14,16 +14,26 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Module-scope singleton per CLI process. Tests must call clearCurrentTransport()
|
|
16
16
|
* in beforeEach to avoid leakage across Vitest test files.
|
|
17
|
+
*
|
|
18
|
+
* Task 9 (ux-wave1) — this setter is ALSO the single statusline wire for the
|
|
19
|
+
* `tr <TRKORR>` segment: every path that changes the session transport
|
|
20
|
+
* (sap_transport create success, owning-transport adoption in the create
|
|
21
|
+
* guard, /transport slash command) funnels through set/clear here, so the
|
|
22
|
+
* globalStore push lives HERE rather than scattered across tool handlers.
|
|
23
|
+
* Store writes are harmless in classic mode (nothing subscribes there).
|
|
17
24
|
*/
|
|
25
|
+
import { globalStore } from '../ui/sap-state-store.js';
|
|
18
26
|
let currentTransport = null;
|
|
19
27
|
export function setCurrentTransport(trkorr) {
|
|
20
28
|
currentTransport = trkorr;
|
|
29
|
+
globalStore.update({ transport: trkorr });
|
|
21
30
|
}
|
|
22
31
|
export function getCurrentTransport() {
|
|
23
32
|
return currentTransport;
|
|
24
33
|
}
|
|
25
34
|
export function clearCurrentTransport() {
|
|
26
35
|
currentTransport = null;
|
|
36
|
+
globalStore.update({ transport: null });
|
|
27
37
|
}
|
|
28
38
|
/**
|
|
29
39
|
* Handles the `/transport [<trkorr>|clear]` slash command. Returns user-facing
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent REPL history — the ONE writer shared by every append site:
|
|
3
|
+
* - classic readline path (repl.tsx)
|
|
4
|
+
* - Ink handleSubmit (repl.tsx)
|
|
5
|
+
* - Ink Footer (ui/footer.tsx)
|
|
6
|
+
*
|
|
7
|
+
* File shape: `~/.cspeach/history`, one entry per line, chronological
|
|
8
|
+
* (oldest at top, newest at bottom), appended on every submit; capped at
|
|
9
|
+
* READ time (HISTORY_SIZE in repl.tsx), not write time.
|
|
10
|
+
*
|
|
11
|
+
* Task 8 (ux-wave1) — consecutive-duplicate dedup lives HERE so all
|
|
12
|
+
* append sites share a single guard (CC parity: bash
|
|
13
|
+
* HISTCONTROL=ignoredups). `x, y, x` stores three entries — only
|
|
14
|
+
* back-to-back repeats collapse, never across gaps. Comparison is on the
|
|
15
|
+
* flattened + trimmed entry, against the file tail — so it also holds
|
|
16
|
+
* across session restarts, AND it collapses the Ink path's historical
|
|
17
|
+
* double append (Footer wrote the entry, then repl.tsx's handleSubmit
|
|
18
|
+
* wrote the same submission again — unflattened).
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
// Resolved lazily (not at module load) so it honours the HOME/USERPROFILE
|
|
24
|
+
// active at call time — lets tests point it at a temp dir.
|
|
25
|
+
export function historyFile() {
|
|
26
|
+
return path.join(os.homedir(), '.cspeach', 'history');
|
|
27
|
+
}
|
|
28
|
+
/** All persisted entries, oldest → newest, trimmed, blank lines dropped. */
|
|
29
|
+
export function readHistoryEntries() {
|
|
30
|
+
try {
|
|
31
|
+
return fs
|
|
32
|
+
.readFileSync(historyFile(), 'utf-8')
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((l) => l.trim())
|
|
35
|
+
.filter((l) => l.length > 0);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Flatten a submission into a single history-file entry: `\r\n` becomes
|
|
43
|
+
* ONE space, then every remaining C0 control + DEL becomes a space.
|
|
44
|
+
*
|
|
45
|
+
* Wave1 fast-follow (tab-flatten): newlines were already flattened, but
|
|
46
|
+
* tabs (e.g. from a pasted tab-indented ABAP snippet) survived into the
|
|
47
|
+
* history file. A tab-bearing entry later rendered in the Ctrl+R fuzzy
|
|
48
|
+
* list dodges Ink's truncate-end — widestLine/string-width measure `\t`
|
|
49
|
+
* as 0 cells while the terminal renders up to 8 — re-opening the exact
|
|
50
|
+
* wrapped-row erase-accounting leak Bug 1 fixed (see text-input.tsx
|
|
51
|
+
* maxDisplayWidth doc). Flattening at WRITE time keeps new entries clean;
|
|
52
|
+
* the fuzzy-row render flattens too, covering pre-fix history files.
|
|
53
|
+
*/
|
|
54
|
+
export function flattenHistoryEntry(line) {
|
|
55
|
+
// eslint-disable-next-line no-control-regex
|
|
56
|
+
return line.replace(/\r\n/g, ' ').replace(/[\x00-\x1f\x7f]/g, ' ').trim();
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Append one entry to the history file.
|
|
60
|
+
* - Submissions are flattened (newlines, tabs, and all other C0
|
|
61
|
+
* controls → ' ') — the file is one-entry-per-line (Task 7 contract)
|
|
62
|
+
* and entries must never carry width-invisible controls (tab-flatten
|
|
63
|
+
* fast-follow; see flattenHistoryEntry).
|
|
64
|
+
* - Skips when the flattened + trimmed entry equals the file's LAST
|
|
65
|
+
* entry (consecutive dedup — trimmed comparison).
|
|
66
|
+
* - Never throws: history persistence failure must not crash the REPL.
|
|
67
|
+
*
|
|
68
|
+
* @returns true when the entry was written, false when skipped.
|
|
69
|
+
*/
|
|
70
|
+
export function appendHistoryEntry(line) {
|
|
71
|
+
const entry = flattenHistoryEntry(line);
|
|
72
|
+
if (!entry)
|
|
73
|
+
return false;
|
|
74
|
+
try {
|
|
75
|
+
const entries = readHistoryEntries();
|
|
76
|
+
if (entries.length > 0 && entries[entries.length - 1] === entry)
|
|
77
|
+
return false;
|
|
78
|
+
const file = historyFile();
|
|
79
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
80
|
+
fs.appendFileSync(file, entry + '\n', 'utf-8');
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InkStdinPasteGuard — bracketed-paste interception for the INK path
|
|
3
|
+
* (wave1 live-fix, Bug 2: multiline paste submitted line-by-line on real
|
|
4
|
+
* Windows Terminal).
|
|
5
|
+
*
|
|
6
|
+
* What the live test proved (and Task 7's assumption it disproved):
|
|
7
|
+
* Windows Terminal does NOT deliver a paste as one big chunk with
|
|
8
|
+
* embedded newlines — pasted newlines arrive as `\r`, and a `\r` can
|
|
9
|
+
* land as its OWN stdin chunk. Ink's parseKeypress flags a chunk that is
|
|
10
|
+
* exactly '\r' as key.return (ink/build/parse-keypress.js:145-148), so
|
|
11
|
+
* every pasted line submitted immediately — the garbled multi-dispatch
|
|
12
|
+
* the user saw.
|
|
13
|
+
*
|
|
14
|
+
* Why the interception must happen BEFORE Ink parses:
|
|
15
|
+
* - With DEC 2004 enabled the paste arrives as `\x1b[200~ … \x1b[201~`,
|
|
16
|
+
* but Ink's parser mangles the markers: parse-keypress' fnKeyRe
|
|
17
|
+
* matches `\x1b[200~` with code '[200~' → name undefined, and
|
|
18
|
+
* use-input.js:73-75 strips the leading ESC from the sequence, so the
|
|
19
|
+
* marker would land in the input as literal garbage text ("[200~…").
|
|
20
|
+
* There is no reliable way to reassemble a marker split across chunks
|
|
21
|
+
* from inside useInput.
|
|
22
|
+
* - The reliable seam is the stdin stream itself: Ink accepts a custom
|
|
23
|
+
* `stdin` in its render options and touches exactly this surface on
|
|
24
|
+
* it (ink/build/components/App.js:104-139): `isTTY`, `setEncoding`,
|
|
25
|
+
* `ref`/`unref`, `setRawMode`, `addListener('readable')` + `read()`.
|
|
26
|
+
* A Transform (Duplex) provides the readable side natively; the four
|
|
27
|
+
* TTY-ish members are shimmed here, forwarding to process.stdin.
|
|
28
|
+
*
|
|
29
|
+
* Wiring (repl.tsx Ink branch): enableBracketedPaste() →
|
|
30
|
+
* `process.stdin.pipe(guard)` → `render(…, { stdin: guard })`. The base
|
|
31
|
+
* class (BracketedPasteDecoder with emitVerbatim) strips the markers,
|
|
32
|
+
* accumulates the whole span — including any `\r`-only chunks inside it —
|
|
33
|
+
* and re-emits the paste as ONE chunk with newlines preserved. Ink then
|
|
34
|
+
* hands that single chunk unsplit to useInput, where text-input's Task-7
|
|
35
|
+
* paste branch buffers complete lines via onPasteLines and keeps the
|
|
36
|
+
* tail fragment. NOTHING can submit mid-paste because no `\r`-only chunk
|
|
37
|
+
* ever reaches parseKeypress between the markers.
|
|
38
|
+
*
|
|
39
|
+
* Terminals without bracketed-paste support simply never send markers:
|
|
40
|
+
* the guard is pass-through and behaviour is exactly the pre-fix Task 7
|
|
41
|
+
* path (no regression, same as the classic-mode caveat).
|
|
42
|
+
*/
|
|
43
|
+
import { BracketedPasteDecoder } from './bracketed-paste.js';
|
|
44
|
+
export class InkStdinPasteGuard extends BracketedPasteDecoder {
|
|
45
|
+
/**
|
|
46
|
+
* Mirror the REAL stdin's TTY-ness (the base class hardcodes true for
|
|
47
|
+
* readline's sake). Ink gates isRawModeSupported on this — lying about
|
|
48
|
+
* a non-TTY stdin would make Ink call setRawMode on a pipe.
|
|
49
|
+
*/
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
51
|
+
isTTY = process.stdin.isTTY ?? false;
|
|
52
|
+
constructor() {
|
|
53
|
+
super({ emitVerbatim: true });
|
|
54
|
+
}
|
|
55
|
+
/** Ink calls stdin.ref()/unref() around raw-mode enable/disable. */
|
|
56
|
+
ref() {
|
|
57
|
+
process.stdin.ref?.();
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
unref() {
|
|
61
|
+
process.stdin.unref?.();
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Ordered from most to least restricted. */
|
|
2
|
+
export const WRITE_MODE_ORDER = ['advisory-only', 'approval-gated', 'auto'];
|
|
3
|
+
const ROLE_CEILING = {
|
|
4
|
+
dev: 'auto',
|
|
5
|
+
qas: 'approval-gated',
|
|
6
|
+
prd: 'advisory-only',
|
|
7
|
+
};
|
|
8
|
+
/** The most permissive write mode a system role allows. */
|
|
9
|
+
export function modeCeiling(role) {
|
|
10
|
+
return role === undefined ? 'auto' : ROLE_CEILING[role];
|
|
11
|
+
}
|
|
12
|
+
/** Clamp a mode to the role's ceiling. */
|
|
13
|
+
export function clampToCeiling(mode, role) {
|
|
14
|
+
const ceiling = modeCeiling(role);
|
|
15
|
+
return WRITE_MODE_ORDER.indexOf(mode) <= WRITE_MODE_ORDER.indexOf(ceiling) ? mode : ceiling;
|
|
16
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { clampToCeiling, modeCeiling, WRITE_MODE_ORDER, } from './mode-ceiling.js';
|
|
2
|
+
/**
|
|
3
|
+
* Session-scoped effective write mode + cycling core (UX Wave 1, Task 2).
|
|
4
|
+
*
|
|
5
|
+
* The config's `write_mode` is the durable setting; this module layers a
|
|
6
|
+
* SESSION override on top (Shift+Tab-style cycling, lost on exit) and clamps
|
|
7
|
+
* the result to the active system role's ceiling (mode-ceiling.ts):
|
|
8
|
+
*
|
|
9
|
+
* effective = clamp(sessionOverride ?? configMode, roleCeiling)
|
|
10
|
+
*
|
|
11
|
+
* Cycling walks WRITE_MODE_ORDER restricted to the modes the role allows:
|
|
12
|
+
* dev / unset → advisory → gated → auto → advisory …
|
|
13
|
+
* qas → advisory → gated → advisory … (auto not in the cycle — a
|
|
14
|
+
* gated→advisory step is a
|
|
15
|
+
* WRAP, not a cap)
|
|
16
|
+
* prd → pinned at advisory-only; a cycle attempt reports
|
|
17
|
+
* `capped: true` so the caller can render the refusal line.
|
|
18
|
+
*
|
|
19
|
+
* Pure module — no I/O, no config access; callers pass config mode + role in.
|
|
20
|
+
* Module-level state is test-resettable via `setSessionWriteMode(null)`.
|
|
21
|
+
*/
|
|
22
|
+
let sessionWriteMode = null;
|
|
23
|
+
/** Session override; null = follow config. Module-level, test-resettable. */
|
|
24
|
+
export function setSessionWriteMode(mode) {
|
|
25
|
+
sessionWriteMode = mode;
|
|
26
|
+
}
|
|
27
|
+
export function getSessionWriteMode() {
|
|
28
|
+
return sessionWriteMode;
|
|
29
|
+
}
|
|
30
|
+
/** cfgMode overlaid by session override, then clamped to the alias role's ceiling. */
|
|
31
|
+
export function getEffectiveWriteMode(cfgMode, role) {
|
|
32
|
+
return clampToCeiling(sessionWriteMode ?? cfgMode, role);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Cycle to the next mode the role allows (wraps). Sets the session override
|
|
36
|
+
* so the new mode takes effect immediately. Returns the new mode AND whether
|
|
37
|
+
* the requested step was capped (for the refusal line).
|
|
38
|
+
*/
|
|
39
|
+
export function cycleWriteMode(cfgMode, role) {
|
|
40
|
+
const ceilingIdx = WRITE_MODE_ORDER.indexOf(modeCeiling(role));
|
|
41
|
+
// Modes at or below the ceiling, in cycling order (most→least restricted).
|
|
42
|
+
const allowed = WRITE_MODE_ORDER.slice(0, ceilingIdx + 1);
|
|
43
|
+
if (allowed.length === 1) {
|
|
44
|
+
// prd: pinned — there is nowhere to step. Report capped for the refusal line.
|
|
45
|
+
setSessionWriteMode(allowed[0]);
|
|
46
|
+
return { mode: allowed[0], capped: true };
|
|
47
|
+
}
|
|
48
|
+
const current = getEffectiveWriteMode(cfgMode, role);
|
|
49
|
+
const next = allowed[(allowed.indexOf(current) + 1) % allowed.length];
|
|
50
|
+
setSessionWriteMode(next);
|
|
51
|
+
return { mode: next, capped: false };
|
|
52
|
+
}
|
|
53
|
+
/** Short /mode arguments → WriteMode. */
|
|
54
|
+
const MODE_ARGS = {
|
|
55
|
+
advisory: 'advisory-only',
|
|
56
|
+
gated: 'approval-gated',
|
|
57
|
+
auto: 'auto',
|
|
58
|
+
};
|
|
59
|
+
function announceLine(prev, next) {
|
|
60
|
+
return `mode: ${prev} → ${next} (session only — config unchanged)`;
|
|
61
|
+
}
|
|
62
|
+
function refusalLine(clamped, role, alias) {
|
|
63
|
+
return `mode capped at ${clamped} — sap.${alias ?? '?'}.role = ${role} (policy)`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* /mode command core (UX Wave 1, Task 3). Pure apart from the session
|
|
67
|
+
* override it shares with cycleWriteMode — the REPL builtin and the
|
|
68
|
+
* Shift+Tab / Alt+M keybindings are thin shells over this.
|
|
69
|
+
*
|
|
70
|
+
* - bare / empty arg → cycle (wraps within the role's allowed modes)
|
|
71
|
+
* - 'advisory'|'gated'|'auto' → jump directly (still clamped to the ceiling;
|
|
72
|
+
* a jump above it returns the refusal line and
|
|
73
|
+
* the clamped mode)
|
|
74
|
+
* - anything else → usage line, mode untouched
|
|
75
|
+
*
|
|
76
|
+
* Returns the line to print (caller dims it) and the resulting effective
|
|
77
|
+
* mode (caller pushes it into the SapStateStore so the statusline is live).
|
|
78
|
+
*/
|
|
79
|
+
export function runModeCommand(arg, cfgMode, role, alias) {
|
|
80
|
+
const prev = getEffectiveWriteMode(cfgMode, role);
|
|
81
|
+
const normalized = arg?.trim().toLowerCase() ?? '';
|
|
82
|
+
if (normalized === '') {
|
|
83
|
+
const { mode, capped } = cycleWriteMode(cfgMode, role);
|
|
84
|
+
// capped is only ever true with a prd role, so `role!` is safe here.
|
|
85
|
+
if (capped)
|
|
86
|
+
return { line: refusalLine(mode, role, alias), mode };
|
|
87
|
+
return { line: announceLine(prev, mode), mode };
|
|
88
|
+
}
|
|
89
|
+
const target = MODE_ARGS[normalized];
|
|
90
|
+
if (target === undefined) {
|
|
91
|
+
return {
|
|
92
|
+
line: `usage: /mode [advisory|gated|auto] — bare /mode cycles (current: ${prev})`,
|
|
93
|
+
mode: prev,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const clamped = clampToCeiling(target, role);
|
|
97
|
+
setSessionWriteMode(clamped);
|
|
98
|
+
if (clamped !== target) {
|
|
99
|
+
// Jump above the ceiling — clamp landed us lower than requested. `role!`
|
|
100
|
+
// is safe: without a role the ceiling is 'auto' and nothing clamps.
|
|
101
|
+
return { line: refusalLine(clamped, role, alias), mode: clamped };
|
|
102
|
+
}
|
|
103
|
+
return { line: announceLine(prev, clamped), mode: clamped };
|
|
104
|
+
}
|
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
* Ink mode → chunkEmitter.emit('chunk', formatted) — Body's
|
|
10
10
|
* <Static> commits the line to scrollback
|
|
11
11
|
*
|
|
12
|
-
* The sapStateStore push that used to drive a Header / Sidebar
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The sapStateStore push that used to drive a Header / Sidebar was
|
|
13
|
+
* removed when the Header died (no consumer). UX Wave 1 Task 9 partially
|
|
14
|
+
* revives it: the Task 4 <StatusLine /> reads dollarSpendToday from the
|
|
15
|
+
* store, and this helper is the one chokepoint that already computes the
|
|
16
|
+
* session cost total after EVERY turn (both classic and Ink paths call
|
|
17
|
+
* it), so the spend wire lives here. The printed status line remains the
|
|
18
|
+
* primary per-turn status surface.
|
|
15
19
|
*/
|
|
16
20
|
import { computeCost } from '../cost/pricing.js';
|
|
21
|
+
import { globalStore } from '../ui/sap-state-store.js';
|
|
17
22
|
import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
|
|
18
23
|
import { shouldUseInk } from '../renderer/tty.js';
|
|
19
24
|
export function snapshotTurnStart(session) {
|
|
@@ -44,6 +49,14 @@ export function pushPostTurnStatus(params) {
|
|
|
44
49
|
cacheRead: session.usage.cache_read_input_tokens,
|
|
45
50
|
cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
|
|
46
51
|
});
|
|
52
|
+
// Task 9 (ux-wave1) — statusline spend wire. Honest semantics note: the
|
|
53
|
+
// store field is named `dollarSpendToday` (legacy sidebar naming, kept to
|
|
54
|
+
// avoid rename churn) but the value written is SESSION-scoped — the same
|
|
55
|
+
// sessionCost total shown in the printed status footer, computed from
|
|
56
|
+
// lifetime session.usage at session.model rates (mixed-model turns are an
|
|
57
|
+
// approximation, same caveat as the footer's session line). There is no
|
|
58
|
+
// day-spanning ledger; the statusline shows session spend.
|
|
59
|
+
globalStore.update({ dollarSpendToday: sessionCost });
|
|
47
60
|
const statusPayload = {
|
|
48
61
|
sessionId: session.id,
|
|
49
62
|
sapAlias: selectedAlias,
|
|
@@ -60,7 +73,14 @@ export function pushPostTurnStatus(params) {
|
|
|
60
73
|
// Push through chunkEmitter so the line lands in Body's <Static>
|
|
61
74
|
// scrollback alongside the conversation, instead of corrupting
|
|
62
75
|
// the live Ink frame.
|
|
63
|
-
|
|
76
|
+
//
|
|
77
|
+
// Wave1 fast-follow — variant:'turn' slims the Ink receipt to
|
|
78
|
+
// turn-only facts (skill │ turns │ tok │ $ │ duration): the
|
|
79
|
+
// persistent <StatusLine /> already shows the alias and the session
|
|
80
|
+
// $ total, so the full receipt double-reported both after every
|
|
81
|
+
// turn. Classic mode (and the no-emitter fallback) keeps the full
|
|
82
|
+
// receipt — there is no statusline there.
|
|
83
|
+
chunkEmitter.emit('chunk', formatStatusFooter({ ...statusPayload, variant: 'turn' }));
|
|
64
84
|
}
|
|
65
85
|
else {
|
|
66
86
|
printStatusFooter(statusPayload);
|
|
@@ -22,12 +22,17 @@ const BUILTIN_COMMANDS = [
|
|
|
22
22
|
'/cancel',
|
|
23
23
|
'/compact',
|
|
24
24
|
'/cost',
|
|
25
|
+
'/context',
|
|
26
|
+
'/export',
|
|
25
27
|
'/exit',
|
|
26
28
|
'/quit',
|
|
27
29
|
'/help',
|
|
28
30
|
'/skills',
|
|
31
|
+
'/mode',
|
|
29
32
|
'/new',
|
|
33
|
+
'/recap',
|
|
30
34
|
'/reset',
|
|
35
|
+
'/rewind',
|
|
31
36
|
'/ui',
|
|
32
37
|
'/reroute',
|
|
33
38
|
'/transport',
|