@cspeach/cli 0.8.0 → 1.0.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/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +330 -36
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/steering-queue.js +27 -0
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +84 -7
- package/dist/approvals/render.js +38 -0
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +24 -5
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +435 -0
- package/dist/config/loader.js +102 -3
- package/dist/cost/pricing.js +14 -5
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +178 -0
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +7 -1
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +254 -0
- package/dist/projects/plan-schema.js +210 -0
- package/dist/projects/promote-command.js +26 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +263 -20
- package/dist/projects/status.js +22 -0
- package/dist/projects/validate.js +2 -0
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +93 -21
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-picker.js +25 -19
- package/dist/repl.js +470 -22
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/pending.js +27 -0
- package/dist/skill-catalog.js +54 -43
- package/dist/skills/bundled-skills.js +279 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-scaffold.js +45 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +84 -11
- package/dist/ui/body.js +13 -0
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +28 -11
- package/dist/ui/line-resolution.js +92 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +30 -2
- package/package.json +23 -4
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
package/dist/repl/at-picker.js
CHANGED
|
@@ -17,11 +17,83 @@
|
|
|
17
17
|
* without screen takeover, Enter when they want a full search-as-you-type
|
|
18
18
|
* picker. Same backing data, different UI.
|
|
19
19
|
*/
|
|
20
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
20
22
|
import { search } from '@inquirer/prompts';
|
|
21
23
|
import { withInquirer } from './inquirer-guard.js';
|
|
22
24
|
import { inquirerTheme } from './inquirer-theme.js';
|
|
23
|
-
import { ensureWorkspaceSync, listProjectFilesSync } from '../projects/workspace.js';
|
|
25
|
+
import { ensureWorkspaceSync, listProjectFilesSync, isAttachableFilename } from '../projects/workspace.js';
|
|
24
26
|
const CANCEL_VALUE = '__cspeach_cancel__';
|
|
27
|
+
/**
|
|
28
|
+
* 2026-06-06 (Ink picker parity): workspace listing + substring filter,
|
|
29
|
+
* exported so the Ink FilePalette shares the same data + seeding
|
|
30
|
+
* semantics as this classic inquirer picker.
|
|
31
|
+
*
|
|
32
|
+
* Bug #4 (2026-06-15): the listing is the UNION of
|
|
33
|
+
* - all workspace files (.cspeach.json envelopes + .txt/.md/.docx/.pdf), AND
|
|
34
|
+
* - top-level .txt/.md/.docx/.pdf files in the current working directory.
|
|
35
|
+
* A spec file dropped next to the project (the common case) lives in the
|
|
36
|
+
* cwd, never in .cspeach/, so without the cwd half the picker was empty.
|
|
37
|
+
*
|
|
38
|
+
* Deduped by filename — when a name exists in both, the workspace entry
|
|
39
|
+
* wins. Non-recursive over the cwd; the .cspeach subfolder and any
|
|
40
|
+
* dotfiles/dirs are skipped. SYNCHRONOUS by contract (the Ink palette and
|
|
41
|
+
* pendingAtTokenPrefix call it sync). `cwd` / `workspace` are injectable
|
|
42
|
+
* for tests; the zero-arg call resolves both from the environment.
|
|
43
|
+
*/
|
|
44
|
+
export function listWorkspaceFileChoices(opts) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
// Workspace half (takes precedence on name collisions).
|
|
48
|
+
try {
|
|
49
|
+
const ws = opts?.workspace ?? ensureWorkspaceSync();
|
|
50
|
+
for (const f of listProjectFilesSync(ws)) {
|
|
51
|
+
if (seen.has(f.filename))
|
|
52
|
+
continue;
|
|
53
|
+
seen.add(f.filename);
|
|
54
|
+
out.push({ filename: f.filename, mtimeMs: f.mtimeMs });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// fall through — still return whatever the cwd half yields
|
|
59
|
+
}
|
|
60
|
+
// cwd half — top-level .txt/.md/.docx/.pdf only. Never throws.
|
|
61
|
+
const cwd = opts?.cwd ?? process.cwd();
|
|
62
|
+
try {
|
|
63
|
+
for (const name of readdirSync(cwd)) {
|
|
64
|
+
if (name.startsWith('.'))
|
|
65
|
+
continue; // dotfiles/dirs (incl. .cspeach)
|
|
66
|
+
if (!isAttachableFilename(name))
|
|
67
|
+
continue;
|
|
68
|
+
if (seen.has(name))
|
|
69
|
+
continue; // workspace already has it
|
|
70
|
+
const full = join(cwd, name);
|
|
71
|
+
let stat;
|
|
72
|
+
try {
|
|
73
|
+
stat = statSync(full);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!stat.isFile())
|
|
79
|
+
continue;
|
|
80
|
+
seen.add(name);
|
|
81
|
+
out.push({ filename: name, mtimeMs: stat.mtimeMs });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// unreadable cwd — return just the workspace list
|
|
86
|
+
}
|
|
87
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** Substring filter; empty prefix returns everything. */
|
|
91
|
+
export function filterFileChoices(prefix, all) {
|
|
92
|
+
const lower = prefix.trim().toLowerCase();
|
|
93
|
+
return lower.length === 0
|
|
94
|
+
? [...all]
|
|
95
|
+
: all.filter((f) => f.filename.toLowerCase().includes(lower));
|
|
96
|
+
}
|
|
25
97
|
/**
|
|
26
98
|
* Open the at-picker pre-filtered to `prefix`. Returns the picked
|
|
27
99
|
* filename (no leading @), or null on cancel / empty-workspace.
|
|
@@ -31,21 +103,11 @@ const CANCEL_VALUE = '__cspeach_cancel__';
|
|
|
31
103
|
* (bare `@`) seeds the picker with all workspace files.
|
|
32
104
|
*/
|
|
33
105
|
export async function openAtPicker(prefix) {
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const ws = ensureWorkspaceSync();
|
|
37
|
-
allFiles = listProjectFilesSync(ws).map((f) => ({ filename: f.filename, mtimeMs: f.mtimeMs }));
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
allFiles = [];
|
|
41
|
-
}
|
|
106
|
+
const allFiles = listWorkspaceFileChoices();
|
|
42
107
|
if (allFiles.length === 0) {
|
|
43
108
|
return null;
|
|
44
109
|
}
|
|
45
|
-
const
|
|
46
|
-
const seeded = lowerPrefix.length === 0
|
|
47
|
-
? allFiles
|
|
48
|
-
: allFiles.filter((f) => f.filename.toLowerCase().includes(lowerPrefix));
|
|
110
|
+
const seeded = filterFileChoices(prefix, allFiles);
|
|
49
111
|
// Single unique match — short-circuit, save the user a keystroke. Same
|
|
50
112
|
// shortcut openSlashPicker uses.
|
|
51
113
|
if (seeded.length === 1) {
|
|
@@ -95,7 +157,7 @@ export async function openAtPicker(prefix) {
|
|
|
95
157
|
* null when the line doesn't end with an @-token or the token already
|
|
96
158
|
* resolves exactly.
|
|
97
159
|
*/
|
|
98
|
-
export function pendingAtTokenPrefix(trimmed) {
|
|
160
|
+
export function pendingAtTokenPrefix(trimmed, knownFilenames) {
|
|
99
161
|
// Match the rightmost @<word> at the end of the line. \S+ means
|
|
100
162
|
// non-whitespace only — `@filename with space` is not auto-detected
|
|
101
163
|
// (user can quote with `@"name with space"` for those, handled by
|
|
@@ -106,16 +168,26 @@ export function pendingAtTokenPrefix(trimmed) {
|
|
|
106
168
|
const prefix = m[1] ?? '';
|
|
107
169
|
// If the prefix already exactly matches a workspace filename, the
|
|
108
170
|
// existing @-resolution will pick it up — don't open the picker.
|
|
171
|
+
// `knownFilenames` lets callers (Ink pipeline, tests) inject the
|
|
172
|
+
// listing instead of hitting the filesystem here.
|
|
109
173
|
if (prefix.length > 0) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const files = listProjectFilesSync(ws);
|
|
113
|
-
const exact = files.some((f) => f.filename === prefix);
|
|
114
|
-
if (exact)
|
|
174
|
+
if (knownFilenames) {
|
|
175
|
+
if (knownFilenames.includes(prefix))
|
|
115
176
|
return null;
|
|
116
177
|
}
|
|
117
|
-
|
|
118
|
-
|
|
178
|
+
else {
|
|
179
|
+
try {
|
|
180
|
+
// Bug #4: check the MERGED listing (workspace + cwd text files),
|
|
181
|
+
// not workspace-only, so an exact `@spec.txt` for a cwd file
|
|
182
|
+
// submits directly instead of re-opening the picker.
|
|
183
|
+
const files = listWorkspaceFileChoices();
|
|
184
|
+
const exact = files.some((f) => f.filename === prefix);
|
|
185
|
+
if (exact)
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Fall through — open picker as a safe default.
|
|
190
|
+
}
|
|
119
191
|
}
|
|
120
192
|
}
|
|
121
193
|
return prefix;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in (non-skill) slash commands — the single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from slash-picker.ts (2026-06-11) so the router layer
|
|
5
|
+
* (src/router/classifier.ts) can distinguish "user typed a builtin with
|
|
6
|
+
* arguments" (e.g. `/help me`) from "user typed an unknown skill"
|
|
7
|
+
* WITHOUT importing the inquirer-backed picker module. Pure data — no
|
|
8
|
+
* runtime dependencies.
|
|
9
|
+
*/
|
|
10
|
+
export const BUILTIN_ENTRIES = [
|
|
11
|
+
{ name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
|
|
12
|
+
{ name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
|
|
13
|
+
{ name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
|
|
14
|
+
{ name: '/exit', description: 'Exit CSPeach' },
|
|
15
|
+
{ name: '/quit', description: 'Exit CSPeach (alias)' },
|
|
16
|
+
{ name: '/help', description: 'Show all skills and commands' },
|
|
17
|
+
{ name: '/skills', description: 'Show all skills (alias)' },
|
|
18
|
+
{ name: '/new', description: 'Reset routing — next prompt is classified afresh' },
|
|
19
|
+
{ name: '/reset', description: 'Reset routing (alias)' },
|
|
20
|
+
{ name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
|
|
21
|
+
{ name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
|
|
22
|
+
{ name: '/transport', description: 'View or set the active transport for this session' },
|
|
23
|
+
{ name: '/safety-mode', description: 'Toggle structured Forge Rule confirmation cards (on / off / status)' },
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Builtin command heads for routing checks. Superset of BUILTIN_ENTRIES:
|
|
27
|
+
* `/files` is a real REPL builtin (repl.tsx handles it in both paths) but
|
|
28
|
+
* is deliberately absent from the picker catalogue, so it's added here.
|
|
29
|
+
*/
|
|
30
|
+
const BUILTIN_HEADS = new Set([
|
|
31
|
+
...BUILTIN_ENTRIES.map((e) => e.name),
|
|
32
|
+
'/files',
|
|
33
|
+
]);
|
|
34
|
+
/** True when `head` (e.g. '/help', as typed, any case) is a builtin command. */
|
|
35
|
+
export function isBuiltinCommandHead(head) {
|
|
36
|
+
return BUILTIN_HEADS.has(head.toLowerCase());
|
|
37
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Early-line buffer for piped-stdin REPL runs — UX-5 #5 (2026-06-12).
|
|
3
|
+
*
|
|
4
|
+
* The bug (found 2026-06-11): lines piped into the classic REPL
|
|
5
|
+
* (`"/cost`n/exit" | cspeach`) arrive on the readline BEFORE the loop's
|
|
6
|
+
* first rl.question() is pending. Node's readline only routes a line to a
|
|
7
|
+
* question callback when one is registered; otherwise it emits the 'line'
|
|
8
|
+
* EVENT — which nobody consumed, so every early line was silently dropped
|
|
9
|
+
* (even /cost never dispatched). stdin EOF then closed the readline and
|
|
10
|
+
* the old 'close' handler hard-exited. This blocked CI-driven multi-phase
|
|
11
|
+
* plan runs entirely.
|
|
12
|
+
*
|
|
13
|
+
* Fix shape:
|
|
14
|
+
* - attach a 'line' listener at readline creation that QUEUES lines
|
|
15
|
+
* arriving while no question is pending (readline emits 'line' only
|
|
16
|
+
* in that case — a pending question consumes the line directly);
|
|
17
|
+
* - the REPL loop drains this queue before calling rl.question;
|
|
18
|
+
* - on stdin EOF ('close') the buffer flips `closed` and the loop keeps
|
|
19
|
+
* processing queued lines before exiting (the repl.tsx close handler
|
|
20
|
+
* no longer hard-exits in piped mode);
|
|
21
|
+
* - the FIRST piped line gets a UTF-8 BOM strip — PowerShell pipes
|
|
22
|
+
* inject one, which used to corrupt the first command head.
|
|
23
|
+
*
|
|
24
|
+
* Only attached when stdin is NOT a TTY (see repl.tsx): in interactive
|
|
25
|
+
* mode, type-ahead during a turn should keep its existing
|
|
26
|
+
* behaviour rather than silently becoming queued commands.
|
|
27
|
+
*/
|
|
28
|
+
/** Strip a single leading UTF-8 BOM (PowerShell pipes inject one). */
|
|
29
|
+
export function stripBom(s) {
|
|
30
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Attach the queueing listeners to `rl` and return the buffer handle.
|
|
34
|
+
* Call once, immediately after readline creation, BEFORE any awaits —
|
|
35
|
+
* piped data starts flowing on the next tick.
|
|
36
|
+
*/
|
|
37
|
+
export function attachEarlyLineBuffer(rl) {
|
|
38
|
+
const queue = [];
|
|
39
|
+
let firstLine = true;
|
|
40
|
+
let isClosed = false;
|
|
41
|
+
rl.on('line', (l) => {
|
|
42
|
+
let line = String(l);
|
|
43
|
+
if (firstLine) {
|
|
44
|
+
line = stripBom(line);
|
|
45
|
+
firstLine = false;
|
|
46
|
+
}
|
|
47
|
+
queue.push(line);
|
|
48
|
+
});
|
|
49
|
+
rl.on('close', () => {
|
|
50
|
+
isClosed = true;
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
size: () => queue.length,
|
|
54
|
+
shift: () => queue.shift(),
|
|
55
|
+
closed: () => isClosed,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Inert buffer for interactive (TTY) runs — keeps the repl.tsx loop free
|
|
60
|
+
* of `isTTY` branches at every drain site.
|
|
61
|
+
*/
|
|
62
|
+
export function noopEarlyLineBuffer() {
|
|
63
|
+
return {
|
|
64
|
+
size: () => 0,
|
|
65
|
+
shift: () => undefined,
|
|
66
|
+
closed: () => false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -28,6 +28,50 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { clearActiveSpinner } from '../renderer/tool-widget.js';
|
|
30
30
|
let state = null;
|
|
31
|
+
/**
|
|
32
|
+
* UX-5 #4 (2026-06-12) — sequence-scoped (debounced) release.
|
|
33
|
+
*
|
|
34
|
+
* Failure mode this fixes: between BACK-TO-BACK inquirer prompts fired by
|
|
35
|
+
* the SAME handler (safety-confirm's two-step select, ask_question's
|
|
36
|
+
* select → custom-input follow-up, /files step 1 → step 2), the old
|
|
37
|
+
* per-prompt release resumed the outer readline the instant prompt N
|
|
38
|
+
* finished — readline repainted its prompt with a stale history/buffer
|
|
39
|
+
* entry for a frame — and then prompt N+1 paused it again. The user saw
|
|
40
|
+
* the old line flash between the two pickers.
|
|
41
|
+
*
|
|
42
|
+
* Scope note (M1, 2026-06-12): consecutive ask_question TOOL calls are
|
|
43
|
+
* separated by a full LLM round-trip (seconds apart) — the 50ms debounce
|
|
44
|
+
* neither covers nor needs to cover those; readline legitimately resumes
|
|
45
|
+
* between turns. The debounce only spans same-handler prompt sequences.
|
|
46
|
+
*
|
|
47
|
+
* Fix shape: the release (rl.resume + suppress-clear) is DEFERRED by
|
|
48
|
+
* RELEASE_DEBOUNCE_MS. If another withInquirer starts inside that window
|
|
49
|
+
* (an ask SEQUENCE), the pending release is cancelled and the outer
|
|
50
|
+
* prompt stays parked across the whole sequence — no repaint between
|
|
51
|
+
* prompts. Only the final prompt's release actually fires.
|
|
52
|
+
*
|
|
53
|
+
* Note: this is a CLASSIC-path concern only. Under Ink, ask_question /
|
|
54
|
+
* approval render through the Ink-native emitter modals (see
|
|
55
|
+
* tools/ask-question.ts shouldUseInk branch) and registerReadline is
|
|
56
|
+
* never called, so withInquirer is a pass-through there.
|
|
57
|
+
*
|
|
58
|
+
* The suppress flag intentionally stays TRUE during the debounce window —
|
|
59
|
+
* any spurious 'close' inquirer emits on release is still swallowed,
|
|
60
|
+
* which is strictly safer than the old nextTick clearing.
|
|
61
|
+
*/
|
|
62
|
+
// 50ms (M4 rationale): covers same-tick and next-macrotask prompt chaining
|
|
63
|
+
// with ~3 frames of margin (one terminal frame ≈ 16ms) while staying far
|
|
64
|
+
// below human-perceptible delay before the outer prompt repaints.
|
|
65
|
+
export const RELEASE_DEBOUNCE_MS = 50;
|
|
66
|
+
let pendingRelease = null;
|
|
67
|
+
/** Re-entrancy depth — release is scheduled only when the OUTERMOST call exits. */
|
|
68
|
+
let activeDepth = 0;
|
|
69
|
+
function cancelPendingRelease() {
|
|
70
|
+
if (pendingRelease) {
|
|
71
|
+
clearTimeout(pendingRelease);
|
|
72
|
+
pendingRelease = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
31
75
|
/**
|
|
32
76
|
* Called by repl.tsx once the main readline is created. The `setSuppress`
|
|
33
77
|
* callback must flip the same boolean the readline 'close' handler
|
|
@@ -38,6 +82,8 @@ export function registerReadline(rl, setSuppress) {
|
|
|
38
82
|
}
|
|
39
83
|
/** Clear the guard — call when the REPL is shutting down. */
|
|
40
84
|
export function unregisterReadline() {
|
|
85
|
+
cancelPendingRelease();
|
|
86
|
+
activeDepth = 0;
|
|
41
87
|
state = null;
|
|
42
88
|
}
|
|
43
89
|
/**
|
|
@@ -66,6 +112,12 @@ export async function withInquirer(fn) {
|
|
|
66
112
|
// which collides with inquirer's prompt rendering — visible as ghost-text
|
|
67
113
|
// and a cursor that hides under the rotating frame while the user types.
|
|
68
114
|
clearActiveSpinner();
|
|
115
|
+
// UX-5 #4 — continuing an ask SEQUENCE: a release from the previous
|
|
116
|
+
// prompt is still pending, so cancel it. The readline stays parked
|
|
117
|
+
// (paused + close-suppressed) across the gap — no stale-line repaint
|
|
118
|
+
// between consecutive prompts.
|
|
119
|
+
cancelPendingRelease();
|
|
120
|
+
activeDepth++;
|
|
69
121
|
// 2026-05-08: snapshot stdin 'data' / 'keypress' listeners BEFORE the
|
|
70
122
|
// inquirer prompt runs. Some inquirer widgets (notably `search`, used
|
|
71
123
|
// by /files step 1) strip ALL listeners on cleanup — including
|
|
@@ -120,11 +172,24 @@ export async function withInquirer(fn) {
|
|
|
120
172
|
// Order matters here:
|
|
121
173
|
// 1. resume stdin so the event loop stays referenced (otherwise the
|
|
122
174
|
// next rl.question prints its prompt and the process exits).
|
|
123
|
-
// 2.
|
|
124
|
-
//
|
|
125
|
-
//
|
|
175
|
+
// 2. SCHEDULE the readline resume + suppress-clear instead of doing
|
|
176
|
+
// them inline (UX-5 #4). If another inquirer prompt opens within
|
|
177
|
+
// RELEASE_DEBOUNCE_MS — an ask sequence — the entry above cancels
|
|
178
|
+
// this timer and the outer readline never repaints between
|
|
179
|
+
// prompts. The timer is deliberately NOT unref'd: it must hold
|
|
180
|
+
// the event loop open across the gap.
|
|
181
|
+
// 3. inside the deferred release, still defer the suppress-clear by
|
|
182
|
+
// one tick so the spurious 'close' that inquirer synchronously
|
|
183
|
+
// emits on release is ignored.
|
|
126
184
|
process.stdin.resume();
|
|
127
|
-
|
|
128
|
-
|
|
185
|
+
activeDepth = Math.max(0, activeDepth - 1);
|
|
186
|
+
if (activeDepth === 0) {
|
|
187
|
+
cancelPendingRelease();
|
|
188
|
+
pendingRelease = setTimeout(() => {
|
|
189
|
+
pendingRelease = null;
|
|
190
|
+
rl.resume();
|
|
191
|
+
process.nextTick(() => setSuppress(false));
|
|
192
|
+
}, RELEASE_DEBOUNCE_MS);
|
|
193
|
+
}
|
|
129
194
|
}
|
|
130
195
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numbered-menu expansion — UX-5 #2 (2026-06-12).
|
|
3
|
+
*
|
|
4
|
+
* Skills frequently end a turn with a plain-text numbered menu:
|
|
5
|
+
*
|
|
6
|
+
* 1. Explain it
|
|
7
|
+
* 2. Show structure
|
|
8
|
+
* 3. Run ATC
|
|
9
|
+
*
|
|
10
|
+
* Users then type `2` at the prompt — and before this module nothing
|
|
11
|
+
* useful happened: the bare digit was either routed to the previous skill
|
|
12
|
+
* as a meaningless answer or burned a classifier round-trip. The
|
|
13
|
+
* pragmatic fix: when the user submits a SHORT pure-number input AND the
|
|
14
|
+
* previous assistant message contains a matching numbered list item,
|
|
15
|
+
* expand the input to that item's text before the turn starts (with a
|
|
16
|
+
* dim echo so the user sees what they picked). Anything else passes
|
|
17
|
+
* through unchanged.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately conservative — expansion fires ONLY when:
|
|
20
|
+
* - the input matches ^[0-9]{1,2}$ (1–2 digit pure number, no spaces)
|
|
21
|
+
* - the MENU-SHAPED TAIL of the last assistant text — its final
|
|
22
|
+
* ~MENU_TAIL_LINES non-empty lines — contains a numbered list with
|
|
23
|
+
* 2+ items ("N. text" or "N · text" line patterns). A real menu sits
|
|
24
|
+
* at the END of the message; numbered plans/steps mid-message
|
|
25
|
+
* (followed by more prose) must NOT hijack a legitimate numeric
|
|
26
|
+
* answer (UX-5 review I2, 2026-06-12).
|
|
27
|
+
* - the typed number is one of the list's item numbers
|
|
28
|
+
*
|
|
29
|
+
* Everything else (no list, out-of-range number, prose input) returns
|
|
30
|
+
* null and the caller treats the input as typed.
|
|
31
|
+
*/
|
|
32
|
+
/** Pure 1–2 digit number, nothing else. */
|
|
33
|
+
const PURE_NUMBER_RE = /^[0-9]{1,2}$/;
|
|
34
|
+
/**
|
|
35
|
+
* A numbered list item at the start of a line: `12. text` or `12 · text`.
|
|
36
|
+
* Bullet variants with `)` etc. are deliberately NOT matched — staying
|
|
37
|
+
* conservative beats over-firing on prose like "step 3) optional".
|
|
38
|
+
*/
|
|
39
|
+
const ITEM_LINE_RE = /^\s*([0-9]{1,2})(?:\.|\s·)\s+(\S.*)$/;
|
|
40
|
+
/**
|
|
41
|
+
* Extract numbered list items from assistant text. Returns one entry per
|
|
42
|
+
* distinct item number — when the same number appears twice (e.g. two
|
|
43
|
+
* separate lists in one message), the LAST occurrence wins, since the
|
|
44
|
+
* most recent list is the one the user is looking at.
|
|
45
|
+
*/
|
|
46
|
+
export function extractNumberedItems(text) {
|
|
47
|
+
const byNumber = new Map();
|
|
48
|
+
for (const line of text.split('\n')) {
|
|
49
|
+
const m = ITEM_LINE_RE.exec(line);
|
|
50
|
+
if (!m)
|
|
51
|
+
continue;
|
|
52
|
+
const n = Number.parseInt(m[1], 10);
|
|
53
|
+
byNumber.set(n, m[2].trim());
|
|
54
|
+
}
|
|
55
|
+
return [...byNumber.entries()].map(([number, text]) => ({ number, text }));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* How many trailing non-empty lines count as the "menu-shaped tail" of an
|
|
59
|
+
* assistant message. 10 fits the largest real skill menus seen in the wild
|
|
60
|
+
* (8 items + a header + a trailing "pick one" line) while excluding
|
|
61
|
+
* numbered plan/step lists that sit mid-message above closing prose.
|
|
62
|
+
*/
|
|
63
|
+
export const MENU_TAIL_LINES = 10;
|
|
64
|
+
/**
|
|
65
|
+
* The final `maxLines` NON-EMPTY lines of `text`, original order, joined
|
|
66
|
+
* with newlines. Blank lines are skipped (menus are often separated from
|
|
67
|
+
* the item lines by blank lines — those shouldn't eat the budget).
|
|
68
|
+
*/
|
|
69
|
+
export function menuTail(text, maxLines = MENU_TAIL_LINES) {
|
|
70
|
+
const lines = text.split('\n');
|
|
71
|
+
const tail = [];
|
|
72
|
+
for (let i = lines.length - 1; i >= 0 && tail.length < maxLines; i--) {
|
|
73
|
+
const line = lines[i];
|
|
74
|
+
if (line.trim().length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
tail.unshift(line);
|
|
77
|
+
}
|
|
78
|
+
return tail.join('\n');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* If `input` is a short pure number selecting an item from a numbered
|
|
82
|
+
* list in the menu-shaped TAIL of `lastAssistantText`, return the
|
|
83
|
+
* expansion; otherwise null (pass the input through unchanged).
|
|
84
|
+
*
|
|
85
|
+
* I2 (2026-06-12): only the final MENU_TAIL_LINES non-empty lines are
|
|
86
|
+
* scanned — a numbered plan mid-message (with prose after it) no longer
|
|
87
|
+
* hijacks a numeric answer the user meant literally.
|
|
88
|
+
*/
|
|
89
|
+
export function maybeExpandNumberSelection(input, lastAssistantText) {
|
|
90
|
+
const trimmed = input.trim();
|
|
91
|
+
if (!PURE_NUMBER_RE.test(trimmed))
|
|
92
|
+
return null;
|
|
93
|
+
if (!lastAssistantText)
|
|
94
|
+
return null;
|
|
95
|
+
const items = extractNumberedItems(menuTail(lastAssistantText));
|
|
96
|
+
if (items.length < 2)
|
|
97
|
+
return null; // not a menu — a lone "1." is prose
|
|
98
|
+
const n = Number.parseInt(trimmed, 10);
|
|
99
|
+
const hit = items.find((i) => i.number === n);
|
|
100
|
+
if (!hit)
|
|
101
|
+
return null; // out of range — pass through
|
|
102
|
+
return {
|
|
103
|
+
text: hit.text,
|
|
104
|
+
echo: `→ ${hit.number}. ${hit.text}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Text of the most recent assistant message in `messages`. Joins the
|
|
109
|
+
* text blocks of that single message (tool_use blocks skipped); handles
|
|
110
|
+
* the defensive string-content case too. Returns null when no assistant
|
|
111
|
+
* message exists.
|
|
112
|
+
*/
|
|
113
|
+
export function lastAssistantText(messages) {
|
|
114
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
115
|
+
const m = messages[i];
|
|
116
|
+
if (m.role !== 'assistant')
|
|
117
|
+
continue;
|
|
118
|
+
if (typeof m.content === 'string')
|
|
119
|
+
return m.content;
|
|
120
|
+
if (Array.isArray(m.content)) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
for (const block of m.content) {
|
|
123
|
+
if (block?.type === 'text' && typeof block.text === 'string')
|
|
124
|
+
parts.push(block.text);
|
|
125
|
+
}
|
|
126
|
+
return parts.join('\n\n');
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
@@ -26,7 +26,7 @@ export function snapshotTurnStart(session) {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
export function pushPostTurnStatus(params) {
|
|
29
|
-
const { session, selectedAlias, snapshot, chunkEmitter } = params;
|
|
29
|
+
const { session, selectedAlias, snapshot, chunkEmitter, turnModel } = params;
|
|
30
30
|
const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
|
|
31
31
|
const lifetimeTokensAfter = session.usage.input_tokens + session.usage.output_tokens;
|
|
32
32
|
const lifetimeTokensBefore = snapshot.input + snapshot.output;
|
|
@@ -37,7 +37,7 @@ export function pushPostTurnStatus(params) {
|
|
|
37
37
|
cacheRead: session.usage.cache_read_input_tokens - snapshot.cacheRead,
|
|
38
38
|
cacheCreate: (session.usage.cache_creation_input_tokens ?? 0) - snapshot.cacheCreate,
|
|
39
39
|
};
|
|
40
|
-
const turnCost = computeCost(session.model, turnCostTokens);
|
|
40
|
+
const turnCost = computeCost(turnModel ?? session.model, turnCostTokens);
|
|
41
41
|
const sessionCost = computeCost(session.model, {
|
|
42
42
|
input: session.usage.input_tokens,
|
|
43
43
|
output: session.usage.output_tokens,
|
|
@@ -82,10 +82,25 @@ export function renderPlanSummary(proposedNext) {
|
|
|
82
82
|
return lines.join('\n');
|
|
83
83
|
}
|
|
84
84
|
function summarizeArgs(args) {
|
|
85
|
-
// Best-effort one-line summary. Pulls the most useful identifier fields
|
|
86
|
-
//
|
|
85
|
+
// Best-effort one-line summary. Pulls the most useful identifier fields so a
|
|
86
|
+
// customer reviewing the batch gate sees WHAT each op targets (Forge Rule 8
|
|
87
|
+
// is unauditable theatre otherwise):
|
|
88
|
+
// - file_write / file_edit → the path
|
|
89
|
+
// - shell_exec → command + args (e.g. `npm run build`)
|
|
90
|
+
// - SAP object writes → type+name, class_name->method_name, transport
|
|
87
91
|
const a = args;
|
|
88
92
|
const parts = [];
|
|
93
|
+
// Local-FS writes (the fiori-build path: 13× file_write + 2× shell_exec) —
|
|
94
|
+
// the path / command is the only meaningful descriptor; surface it FIRST.
|
|
95
|
+
if (typeof a['path'] === 'string' && a['path']) {
|
|
96
|
+
parts.push(a['path']);
|
|
97
|
+
}
|
|
98
|
+
if (typeof a['command'] === 'string' && a['command']) {
|
|
99
|
+
const cmdArgs = Array.isArray(a['args'])
|
|
100
|
+
? a['args'].filter((x) => typeof x === 'string').join(' ')
|
|
101
|
+
: '';
|
|
102
|
+
parts.push(cmdArgs ? `${a['command']} ${cmdArgs}` : a['command']);
|
|
103
|
+
}
|
|
89
104
|
if (typeof a['type'] === 'string' && typeof a['name'] === 'string') {
|
|
90
105
|
parts.push(`${a['type']} ${a['name']}`);
|
|
91
106
|
}
|