@cspeach/cli 0.8.0 → 0.9.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/loop.js +123 -18
- package/dist/agent/steering-queue.js +27 -0
- package/dist/approvals/jwt.js +45 -5
- package/dist/approvals/render.js +38 -0
- package/dist/classifier/client.js +6 -2
- package/dist/commands/plan-resume.js +308 -0
- package/dist/config/loader.js +7 -2
- package/dist/cost/pricing.js +14 -5
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-plan.js +85 -0
- package/dist/projects/index.js +3 -0
- package/dist/projects/plan-run.js +120 -0
- package/dist/projects/plan-schema.js +150 -0
- package/dist/projects/promote-command.js +1 -0
- package/dist/projects/save-command.js +17 -1
- package/dist/projects/status.js +20 -0
- package/dist/projects/validate.js +2 -0
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/repl/at-picker.js +39 -20
- package/dist/repl/slash-picker.js +15 -4
- package/dist/repl.js +195 -13
- package/dist/skill-catalog.js +8 -2
- package/dist/ui/app.js +80 -11
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +8 -5
- package/dist/ui/line-resolution.js +81 -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 +26 -1
- package/package.json +5 -2
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// cspeach-cli/src/ui/line-resolution.ts
|
|
2
|
+
//
|
|
3
|
+
// Pure pre-dispatch resolution pipeline for the Ink input path
|
|
4
|
+
// (2026-06-06 — Ink parity with the classic REPL's slash/at pickers,
|
|
5
|
+
// repl.tsx ~1535-1640). Given a submitted line, decide ONE of:
|
|
6
|
+
//
|
|
7
|
+
// submit — line is fully resolved; hand it to the REPL handler
|
|
8
|
+
// open-slash — line starts with a non-exact slash command; the App
|
|
9
|
+
// mounts the CommandPalette seeded with the typed prefix
|
|
10
|
+
// open-at — line ends with a partial/unresolved @-token; the App
|
|
11
|
+
// mounts the FilePalette seeded with the typed prefix
|
|
12
|
+
// prefill — skill command with no body; put `<cmd> ` back in the
|
|
13
|
+
// input instead of dispatching an empty prompt the API
|
|
14
|
+
// would reject (mirrors classic repl.tsx ~1626)
|
|
15
|
+
//
|
|
16
|
+
// Same auto-shortcuts as the classic pickers: a prefix that narrows to
|
|
17
|
+
// exactly ONE command / file resolves silently — no palette, no extra
|
|
18
|
+
// keystroke. The App calls nextResolutionStep again after every palette
|
|
19
|
+
// pick, so a line flows slash → at → empty-body guard → submit no
|
|
20
|
+
// matter where it entered the pipeline.
|
|
21
|
+
import { isExactSlashMatch, filterSlashEntries } from '../repl/slash-picker.js';
|
|
22
|
+
import { pendingAtTokenPrefix, filterFileChoices } from '../repl/at-picker.js';
|
|
23
|
+
import { SKILL_CATALOG } from '../skill-catalog.js';
|
|
24
|
+
export function nextResolutionStep(line, files) {
|
|
25
|
+
const trimmed = line.trim();
|
|
26
|
+
if (trimmed.startsWith('/') && !isExactSlashMatch(trimmed)) {
|
|
27
|
+
const headEnd = trimmed.search(/\s/);
|
|
28
|
+
const head = headEnd === -1 ? trimmed : trimmed.slice(0, headEnd);
|
|
29
|
+
const rest = headEnd === -1 ? '' : trimmed.slice(headEnd);
|
|
30
|
+
const prefix = head.slice(1);
|
|
31
|
+
const seeded = filterSlashEntries(prefix);
|
|
32
|
+
if (seeded.length === 1) {
|
|
33
|
+
// Unique match — resolve silently and continue down the pipeline.
|
|
34
|
+
return nextResolutionStep(`${seeded[0].name}${rest}`, files);
|
|
35
|
+
}
|
|
36
|
+
return { kind: 'open-slash', prefix, rest };
|
|
37
|
+
}
|
|
38
|
+
const filenames = files.map((f) => f.filename);
|
|
39
|
+
const atPrefix = pendingAtTokenPrefix(trimmed, filenames);
|
|
40
|
+
if (atPrefix !== null && files.length > 0) {
|
|
41
|
+
const seeded = filterFileChoices(atPrefix, files);
|
|
42
|
+
if (seeded.length === 1) {
|
|
43
|
+
return emptyBodyGuard(trimmed.replace(/@\S*$/, `@${seeded[0].filename}`));
|
|
44
|
+
}
|
|
45
|
+
return { kind: 'open-at', prefix: atPrefix, line: trimmed };
|
|
46
|
+
}
|
|
47
|
+
// Empty workspace: nothing to pick — let the downstream @-resolution
|
|
48
|
+
// produce its normal "no file matching" message.
|
|
49
|
+
return emptyBodyGuard(trimmed);
|
|
50
|
+
}
|
|
51
|
+
/** Substitute the picked command head into the pending line and continue. */
|
|
52
|
+
export function applySlashPick(pickedName, rest, files) {
|
|
53
|
+
const name = pickedName.startsWith('/') ? pickedName : `/${pickedName}`;
|
|
54
|
+
return nextResolutionStep(`${name}${rest}`, files);
|
|
55
|
+
}
|
|
56
|
+
/** Substitute the picked filename into the rightmost @-token and continue. */
|
|
57
|
+
export function applyAtPick(line, pickedFilename, files) {
|
|
58
|
+
return nextResolutionStep(line.replace(/@\S*$/, `@${pickedFilename}`), files);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A SKILL route with an empty body would dispatch an empty prompt the
|
|
62
|
+
* LLM API rejects — prefill `<cmd> ` instead (classic repl.tsx ~1626).
|
|
63
|
+
* Builtins (/help, /exit, /transport …) legitimately take no body and
|
|
64
|
+
* pass straight through.
|
|
65
|
+
*/
|
|
66
|
+
function emptyBodyGuard(line) {
|
|
67
|
+
if (line.startsWith('/')) {
|
|
68
|
+
const splitIdx = line.search(/\s/);
|
|
69
|
+
const head = splitIdx === -1 ? line : line.slice(0, splitIdx);
|
|
70
|
+
const body = splitIdx === -1 ? '' : line.slice(splitIdx).trim();
|
|
71
|
+
const isSkill = SKILL_CATALOG.some((s) => `/${s.name}` === head.toLowerCase());
|
|
72
|
+
if (isSkill && body.length === 0) {
|
|
73
|
+
return {
|
|
74
|
+
kind: 'prefill',
|
|
75
|
+
text: `${head} `,
|
|
76
|
+
hint: `↪ ${head} — type your prompt and press Enter`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { kind: 'submit', line };
|
|
81
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// cspeach-cli/src/ui/turn-status-emitter.ts
|
|
2
|
+
//
|
|
3
|
+
// Turn-lifecycle channel for the persistent status row (2026-06-07 —
|
|
4
|
+
// the CC-style "always alive while working" indicator, B5 smoke
|
|
5
|
+
// feedback round 3: fresh-line heartbeats were not enough).
|
|
6
|
+
//
|
|
7
|
+
// Same singleton-emitter pattern as error-emitter / approval-emitter:
|
|
8
|
+
// the REPL marks turn start/stop around runTurn (try/finally so a
|
|
9
|
+
// thrown turn can never leave the row stuck), loop.ts narrates the
|
|
10
|
+
// current activity, and <TurnStatus /> renders it in the dynamic Ink
|
|
11
|
+
// frame. In classic mode App is never mounted, the emitter has no
|
|
12
|
+
// listeners, and every call is a no-op.
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
14
|
+
class TurnStatusEmitter extends EventEmitter {
|
|
15
|
+
state = { active: false, paused: false, label: '', startedAt: 0 };
|
|
16
|
+
start(label = 'thinking') {
|
|
17
|
+
this.state = { active: true, paused: false, label, startedAt: Date.now() };
|
|
18
|
+
this.emit('change', this.state);
|
|
19
|
+
}
|
|
20
|
+
/** Update the activity label; ignored when no turn is active. Clears pause —
|
|
21
|
+
* a real activity means the model is working again. */
|
|
22
|
+
activity(label) {
|
|
23
|
+
if (!this.state.active)
|
|
24
|
+
return;
|
|
25
|
+
this.state = { ...this.state, label, paused: false };
|
|
26
|
+
this.emit('change', this.state);
|
|
27
|
+
}
|
|
28
|
+
/** Blocked on user input — stop ticking and hide the row until resume(). */
|
|
29
|
+
pause() {
|
|
30
|
+
if (!this.state.active || this.state.paused)
|
|
31
|
+
return;
|
|
32
|
+
this.state = { ...this.state, paused: true };
|
|
33
|
+
this.emit('change', this.state);
|
|
34
|
+
}
|
|
35
|
+
/** User answered — the model is working again; bring the row back. */
|
|
36
|
+
resume() {
|
|
37
|
+
if (!this.state.active || !this.state.paused)
|
|
38
|
+
return;
|
|
39
|
+
this.state = { ...this.state, paused: false };
|
|
40
|
+
this.emit('change', this.state);
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
if (!this.state.active)
|
|
44
|
+
return;
|
|
45
|
+
this.state = { active: false, paused: false, label: '', startedAt: 0 };
|
|
46
|
+
this.emit('change', this.state);
|
|
47
|
+
}
|
|
48
|
+
snapshot() {
|
|
49
|
+
return this.state;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export const turnStatusEmitter = new TurnStatusEmitter();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { Text } from 'ink';
|
|
4
|
+
import { turnStatusEmitter } from './turn-status-emitter.js';
|
|
5
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
6
|
+
const TICK_MS = 250;
|
|
7
|
+
const PEACH = '#FFCBA4';
|
|
8
|
+
function fmtElapsed(ms) {
|
|
9
|
+
const s = Math.floor(ms / 1000);
|
|
10
|
+
if (s < 60)
|
|
11
|
+
return `${s}s`;
|
|
12
|
+
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Persistent turn-status row (2026-06-07) — lives in the DYNAMIC Ink
|
|
16
|
+
* frame just above the Footer, so it repaints with the frame and
|
|
17
|
+
* vanishes the instant the turn ends. While a turn is in flight:
|
|
18
|
+
*
|
|
19
|
+
* ⠹ sap_create_object · 34s · ctrl+c to interrupt
|
|
20
|
+
*
|
|
21
|
+
* The label follows the loop's narration (thinking / tool name /
|
|
22
|
+
* writing response / save check). Its presence answers "is it still
|
|
23
|
+
* working?"; its absence answers "is it done?" — the ambiguity that
|
|
24
|
+
* burned the B5 smoke three times.
|
|
25
|
+
*
|
|
26
|
+
* Pure Ink — no raw stdout writes, no cursor escape codes (the phantom
|
|
27
|
+
* cursor #25 class of bug came from a raw-stdout spinner; this
|
|
28
|
+
* component cannot reproduce it). Guard: ink-cursor-invariant test.
|
|
29
|
+
*/
|
|
30
|
+
export function TurnStatus() {
|
|
31
|
+
const [state, setState] = useState(turnStatusEmitter.snapshot());
|
|
32
|
+
const [frame, setFrame] = useState(0);
|
|
33
|
+
const [now, setNow] = useState(Date.now());
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const onChange = (s) => setState({ ...s });
|
|
36
|
+
turnStatusEmitter.on('change', onChange);
|
|
37
|
+
// Re-sync from the snapshot AFTER subscribing — an event fired between
|
|
38
|
+
// mount and this effect (e.g. a turn starting while App is still
|
|
39
|
+
// mounting) would otherwise be missed and the row stays blank.
|
|
40
|
+
setState({ ...turnStatusEmitter.snapshot() });
|
|
41
|
+
return () => { turnStatusEmitter.off('change', onChange); };
|
|
42
|
+
}, []);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
// Don't tick while paused (blocked on user input) — a live tick here
|
|
45
|
+
// repaints the dynamic frame every 250ms and overdraws an inquirer
|
|
46
|
+
// approval picker / churns the Ink ask_question modal (stacked borders,
|
|
47
|
+
// lost keypress). The row resumes the instant the user answers.
|
|
48
|
+
if (!state.active || state.paused)
|
|
49
|
+
return;
|
|
50
|
+
const interval = setInterval(() => {
|
|
51
|
+
setFrame((f) => (f + 1) % FRAMES.length);
|
|
52
|
+
setNow(Date.now());
|
|
53
|
+
}, TICK_MS);
|
|
54
|
+
return () => clearInterval(interval);
|
|
55
|
+
}, [state.active, state.paused]);
|
|
56
|
+
if (!state.active || state.paused)
|
|
57
|
+
return null;
|
|
58
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: PEACH, children: [FRAMES[frame], " "] }), _jsx(Text, { children: state.label }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", fmtElapsed(now - state.startedAt), " \u00B7 ctrl+c to interrupt"] })] }));
|
|
59
|
+
}
|
|
@@ -15,11 +15,36 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
15
15
|
* learned the hard way that absolute overlays paint over Body and
|
|
16
16
|
* can't be navigated cleanly).
|
|
17
17
|
*/
|
|
18
|
-
import { useState } from 'react';
|
|
18
|
+
import { useState, useEffect } from 'react';
|
|
19
19
|
import { Box, Text, useInput } from 'ink';
|
|
20
20
|
import TextInput from 'ink-text-input';
|
|
21
21
|
const PEACH = '#FFCBA4';
|
|
22
|
+
/**
|
|
23
|
+
* Reclaim raw-mode stdin when an Ink modal mounts (2026-06-08).
|
|
24
|
+
*
|
|
25
|
+
* The approval plan-gate uses `@inquirer/prompts`, which toggles the
|
|
26
|
+
* terminal's raw mode (and pauses stdin) out from under Ink while it runs.
|
|
27
|
+
* When it releases, Ink's INTERNAL bookkeeping still thinks raw mode is on,
|
|
28
|
+
* so it does not re-assert it — and a modal mounted afterwards (this one)
|
|
29
|
+
* receives no keypresses: arrow keys and Enter are dead, the user cannot
|
|
30
|
+
* answer the question. Re-syncing the OS state to what Ink expects on mount
|
|
31
|
+
* fixes it. Direct `process.stdin` calls (not Ink's ref-counted setRawMode)
|
|
32
|
+
* because the whole problem is that Ink's count is stale.
|
|
33
|
+
*/
|
|
34
|
+
function useReclaimRawStdin() {
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const stdin = process.stdin;
|
|
37
|
+
if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
|
|
38
|
+
try {
|
|
39
|
+
stdin.setRawMode(true);
|
|
40
|
+
}
|
|
41
|
+
catch { /* not a TTY — ignore */ }
|
|
42
|
+
stdin.resume();
|
|
43
|
+
}
|
|
44
|
+
}, []);
|
|
45
|
+
}
|
|
22
46
|
export function AskQuestionModal({ params, onResolve }) {
|
|
47
|
+
useReclaimRawStdin();
|
|
23
48
|
if (params.kind === 'text' || params.choices.length === 0) {
|
|
24
49
|
return _jsx(TextInputForm, { params: params, onResolve: onResolve });
|
|
25
50
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cspeach/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abap",
|
|
@@ -82,6 +82,9 @@
|
|
|
82
82
|
"vitest": "^2.1.0"
|
|
83
83
|
},
|
|
84
84
|
"pnpm": {
|
|
85
|
-
"onlyBuiltDependencies": [
|
|
85
|
+
"onlyBuiltDependencies": [
|
|
86
|
+
"keytar",
|
|
87
|
+
"esbuild"
|
|
88
|
+
]
|
|
86
89
|
}
|
|
87
90
|
}
|