@cspeach/cli 0.7.1 → 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/bench/README.md +78 -0
- package/bench/prompts/abap-document-cds.md +44 -0
- package/bench/prompts/abap-explain-bdef-handler.md +57 -0
- package/bench/prompts/abap-test-method.md +42 -0
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +189 -0
- package/bench/results/abap-document-cds/claude-opus-4-7.md +120 -0
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +151 -0
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +112 -0
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +101 -0
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +101 -0
- package/bench/results/abap-test-method/claude-haiku-4-5.md +186 -0
- package/bench/results/abap-test-method/claude-opus-4-7.md +193 -0
- package/bench/results/abap-test-method/claude-sonnet-4-6.md +234 -0
- package/dist/agent/loop.js +144 -26
- 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 +13 -6
- package/dist/cost/pricing.js +14 -5
- package/dist/one-shot.js +6 -0
- 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/question-normalizer.js +16 -6
- 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 +216 -17
- package/dist/router/classifier.js +74 -8
- package/dist/skill-catalog.js +8 -2
- package/dist/tools/subagent/agent_run.js +12 -0
- package/dist/ui/app.js +80 -11
- package/dist/ui/body.js +61 -2
- 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 +6 -2
package/dist/ui/app.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
3
3
|
import { Box, useInput } from 'ink';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { Body } from './body.js';
|
|
6
6
|
import { Footer } from './footer.js';
|
|
7
7
|
import { ApprovalModal } from './approval-modal.js';
|
|
8
8
|
import { CommandPalette } from './command-palette.js';
|
|
9
|
+
import { FilePalette } from './file-palette.js';
|
|
10
|
+
import { nextResolutionStep, applySlashPick, applyAtPick } from './line-resolution.js';
|
|
11
|
+
import { TurnStatus } from './turn-status.js';
|
|
12
|
+
import { turnStatusEmitter } from './turn-status-emitter.js';
|
|
13
|
+
import { listWorkspaceFileChoices } from '../repl/at-picker.js';
|
|
9
14
|
import { RichErrorBox } from './rich-error-box.js';
|
|
10
15
|
import { SessionTimeline, toTimelineEntry } from './session-timeline.js';
|
|
11
16
|
import { approvalEmitter } from './approval-emitter.js';
|
|
@@ -15,6 +20,7 @@ import { coachingPickerEmitter } from './coaching-picker-emitter.js';
|
|
|
15
20
|
import { CoachingPicker } from './widgets/coaching-picker.js';
|
|
16
21
|
import { askQuestionEmitter } from './ask-question-emitter.js';
|
|
17
22
|
import { AskQuestionModal } from './widgets/ask-question-modal.js';
|
|
23
|
+
import { enqueueSteering } from '../agent/steering-queue.js';
|
|
18
24
|
/**
|
|
19
25
|
* App — inline-mode Ink frame (Phase D6, 2026-05-17). After the
|
|
20
26
|
* alt-screen detour, reverted to CC's architecture:
|
|
@@ -33,8 +39,13 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
33
39
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
34
40
|
const [coachingPending, setCoachingPending] = useState(null);
|
|
35
41
|
const [askQuestionPending, setAskQuestionPending] = useState(null);
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
// 2026-06-06 (Ink picker parity) — pre-dispatch line resolution. A
|
|
43
|
+
// submitted line flows through nextResolutionStep (line-resolution.ts);
|
|
44
|
+
// open-slash / open-at steps mount the matching palette seeded with the
|
|
45
|
+
// user's typed fragment; cancel restores the original line into the
|
|
46
|
+
// Footer via `prefill`. Replaces the old bare-`/`-only palette flow.
|
|
47
|
+
const [lineResolution, setLineResolution] = useState(null);
|
|
48
|
+
const [prefill, setPrefill] = useState({ text: '', nonce: 0 });
|
|
38
49
|
const [errorStack, setErrorStack] = useState([]);
|
|
39
50
|
const [showTimeline, setShowTimeline] = useState(false);
|
|
40
51
|
const [footerValue, setFooterValue] = useState('');
|
|
@@ -72,20 +83,73 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
72
83
|
errorEmitter.on('error', onError);
|
|
73
84
|
return () => { errorEmitter.off('error', onError); };
|
|
74
85
|
}, []);
|
|
86
|
+
// Run one step of the pre-dispatch pipeline and act on the outcome.
|
|
87
|
+
const handleStep = useCallback((step, original, files) => {
|
|
88
|
+
switch (step.kind) {
|
|
89
|
+
case 'submit':
|
|
90
|
+
onSubmit(step.line);
|
|
91
|
+
return;
|
|
92
|
+
case 'prefill':
|
|
93
|
+
chunkEmitter.emit('chunk', `\x1b[2m${step.hint}\x1b[22m\n`);
|
|
94
|
+
setPrefill((p) => ({ text: step.text, nonce: p.nonce + 1 }));
|
|
95
|
+
return;
|
|
96
|
+
case 'open-slash':
|
|
97
|
+
setLineResolution({ mode: 'slash', prefix: step.prefix, rest: step.rest, original, files });
|
|
98
|
+
return;
|
|
99
|
+
case 'open-at':
|
|
100
|
+
setLineResolution({ mode: 'at', prefix: step.prefix, line: step.line, original, files });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}, [onSubmit, chunkEmitter]);
|
|
104
|
+
// Footer submissions enter here instead of going straight to the REPL
|
|
105
|
+
// handler — slash + @ tokens get resolved (palette if ambiguous) first.
|
|
106
|
+
const beginSubmit = useCallback((line) => {
|
|
107
|
+
// 2026-06-07 (B5 smoke): Enter while a turn is streaming used to slip
|
|
108
|
+
// through — the pipeline opened a palette OVER the live stream, and a
|
|
109
|
+
// pick would have dispatched a SECOND concurrent turn into the same
|
|
110
|
+
// session. Keep the typed text in the input and tell the user; proper
|
|
111
|
+
// CC-style input queueing is a follow-up (docs/backlog.md).
|
|
112
|
+
if (turnStatusEmitter.snapshot().active) {
|
|
113
|
+
// CC-style mid-turn steering (2026-06-08): the user typed a correction
|
|
114
|
+
// while the model works. Queue it — the turn loop injects it as a user
|
|
115
|
+
// message at the next round boundary — clear the box, and confirm it
|
|
116
|
+
// landed. (Was: bounce the input and tell them to wait.)
|
|
117
|
+
const t = line.trim();
|
|
118
|
+
if (t.length > 0) {
|
|
119
|
+
enqueueSteering(t);
|
|
120
|
+
chunkEmitter.emit('chunk', `\x1b[36m↳ queued ▸ ${t}\x1b[39m\n`);
|
|
121
|
+
}
|
|
122
|
+
setPrefill((p) => ({ text: '', nonce: p.nonce + 1 }));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const files = listWorkspaceFileChoices();
|
|
126
|
+
handleStep(nextResolutionStep(line, files), line, files);
|
|
127
|
+
}, [handleStep, chunkEmitter]);
|
|
128
|
+
// Palette cancel: restore the user's original line so they can edit it.
|
|
129
|
+
const cancelResolution = useCallback(() => {
|
|
130
|
+
const lr = lineResolution;
|
|
131
|
+
setLineResolution(null);
|
|
132
|
+
if (lr && lr.original.trim().length > 0) {
|
|
133
|
+
setPrefill((p) => ({ text: lr.original, nonce: p.nonce + 1 }));
|
|
134
|
+
}
|
|
135
|
+
}, [lineResolution]);
|
|
75
136
|
// `?` key opens the timeline when the footer is empty and no other overlay
|
|
76
137
|
// is active.
|
|
77
138
|
useInput((input, _key) => {
|
|
78
139
|
if (input === '?' && footerValue === '')
|
|
79
140
|
setShowTimeline(true);
|
|
80
|
-
}, { isActive: !
|
|
141
|
+
}, { isActive: !lineResolution && !showTimeline && !pendingApproval && !coachingPending });
|
|
81
142
|
// Ctrl+C handler — abort in-flight turn or exit at idle.
|
|
82
143
|
useInput((input, key) => {
|
|
83
144
|
if (key.ctrl && input === 'c')
|
|
84
145
|
onInterrupt?.();
|
|
85
146
|
}, { isActive: true });
|
|
86
147
|
const clearApproval = () => setPendingApproval(null);
|
|
148
|
+
// Bare `/` + Enter — browse the full catalogue. Same palette as the
|
|
149
|
+
// typed-prefix flow, just unfiltered, with nothing to restore on cancel.
|
|
87
150
|
const handleSlashTrigger = () => {
|
|
88
|
-
|
|
151
|
+
const files = listWorkspaceFileChoices();
|
|
152
|
+
setLineResolution({ mode: 'slash', prefix: '', rest: '', original: '', files });
|
|
89
153
|
onSlashTrigger?.();
|
|
90
154
|
};
|
|
91
155
|
const handleHistoryTrigger = () => {
|
|
@@ -103,18 +167,23 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
|
|
|
103
167
|
} })), askQuestionPending && (_jsx(AskQuestionModal, { params: askQuestionPending, onResolve: (result) => {
|
|
104
168
|
askQuestionPending.resolve(result);
|
|
105
169
|
setAskQuestionPending(null);
|
|
106
|
-
} })), _jsx(Footer, { onSubmit:
|
|
170
|
+
} })), _jsx(TurnStatus, {}), _jsx(Footer, { onSubmit: beginSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, prefill: prefill, onValueChange: setFooterValue,
|
|
107
171
|
// 2026-05-17 — disable Footer input while any modal owns
|
|
108
172
|
// input, so keystrokes don't double-fire (modal answer +
|
|
109
173
|
// spurious Footer submission with same text).
|
|
110
174
|
disabled: !!coachingPending
|
|
111
175
|
|| !!askQuestionPending
|
|
112
176
|
|| !!pendingApproval
|
|
113
|
-
||
|
|
114
|
-
|| showTimeline }),
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
177
|
+
|| !!lineResolution
|
|
178
|
+
|| showTimeline }), lineResolution?.mode === 'slash' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { initialQuery: lineResolution.prefix, onSelect: (name) => {
|
|
179
|
+
const lr = lineResolution;
|
|
180
|
+
setLineResolution(null);
|
|
181
|
+
handleStep(applySlashPick(name, lr.rest, lr.files), lr.original, lr.files);
|
|
182
|
+
}, onCancel: cancelResolution }) })), lineResolution?.mode === 'at' && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(FilePalette, { files: lineResolution.files, initialQuery: lineResolution.prefix, onSelect: (filename) => {
|
|
183
|
+
const lr = lineResolution;
|
|
184
|
+
setLineResolution(null);
|
|
185
|
+
handleStep(applyAtPick(lr.line, filename, lr.files), lr.original, lr.files);
|
|
186
|
+
}, onCancel: cancelResolution }) })), errorStack.map((e) => (_jsx(Box, { children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })), pendingApproval && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
|
|
118
187
|
pendingApproval.resolve({ approved: true });
|
|
119
188
|
clearApproval();
|
|
120
189
|
}, onSkip: () => {
|
package/dist/ui/body.js
CHANGED
|
@@ -3,6 +3,56 @@ import React, { useReducer, useEffect } from 'react';
|
|
|
3
3
|
import { Text, Static } from 'ink';
|
|
4
4
|
import { WIDGET_COMPONENTS } from './widgets/component-registry.js';
|
|
5
5
|
import { WIDGET_CONTROL } from './widget-control.js';
|
|
6
|
+
/**
|
|
7
|
+
* Frame interval for batching streaming chunks before dispatching to the
|
|
8
|
+
* reducer (W2.2 — 2026-05-18). Ink already throttles its stdout writes to
|
|
9
|
+
* 32 ms via `es-toolkit/compat throttle` at the renderer level — but the
|
|
10
|
+
* React **reducer** still runs once per chunk in our useReducer setup.
|
|
11
|
+
* For a long code-gen turn (5000+ chunks of 1-3 bytes each), the cumulative
|
|
12
|
+
* regex-split + string-concat cost in `bodyReducer` becomes the hotspot.
|
|
13
|
+
*
|
|
14
|
+
* Batching here gives one reducer call per ~16 ms regardless of chunk rate,
|
|
15
|
+
* preserving the perceived "real-time stream" feel (still 60Hz) while
|
|
16
|
+
* collapsing reducer work by 1-2 orders of magnitude on dense streams.
|
|
17
|
+
*
|
|
18
|
+
* `requestAnimationFrame` is unavailable in Node, so we use `setTimeout(16)` —
|
|
19
|
+
* same effective cadence, cross-platform safe. We re-arm the timer only when
|
|
20
|
+
* a chunk arrives and no flush is pending, so an idle stream pays nothing.
|
|
21
|
+
*/
|
|
22
|
+
export const RENDER_BATCH_MS = 16;
|
|
23
|
+
export function createChunkBatcher(onFlush, intervalMs = RENDER_BATCH_MS) {
|
|
24
|
+
let buffered = '';
|
|
25
|
+
let timer = null;
|
|
26
|
+
const fire = () => {
|
|
27
|
+
timer = null;
|
|
28
|
+
if (buffered.length === 0)
|
|
29
|
+
return;
|
|
30
|
+
const out = buffered;
|
|
31
|
+
buffered = '';
|
|
32
|
+
onFlush(out);
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
push(chunk) {
|
|
36
|
+
if (chunk.length === 0)
|
|
37
|
+
return;
|
|
38
|
+
buffered += chunk;
|
|
39
|
+
if (timer === null) {
|
|
40
|
+
timer = setTimeout(fire, intervalMs);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
dispose() {
|
|
44
|
+
if (timer !== null) {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
timer = null;
|
|
47
|
+
}
|
|
48
|
+
if (buffered.length > 0) {
|
|
49
|
+
const out = buffered;
|
|
50
|
+
buffered = '';
|
|
51
|
+
onFlush(out);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
6
56
|
// WIDGET_CONTROL is the longer sentinel (`\x00CSPEACH-WIDGET-V3\x00`).
|
|
7
57
|
// RegExp escaping: `\x00` works in JS regex source directly.
|
|
8
58
|
const WIDGET_RE = new RegExp(WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
@@ -105,9 +155,18 @@ function renderNode(n) {
|
|
|
105
155
|
export const Body = React.memo(function Body({ chunkEmitter }) {
|
|
106
156
|
const [state, dispatch] = useReducer(bodyReducer, INITIAL_BODY_STATE);
|
|
107
157
|
useEffect(() => {
|
|
108
|
-
|
|
158
|
+
// W2.2 — chunk batching. createChunkBatcher buffers incoming chunks
|
|
159
|
+
// and dispatches at most once per RENDER_BATCH_MS window. Idle stream
|
|
160
|
+
// pays nothing; dense stream (e.g. 5000 chunks during code-gen) sees
|
|
161
|
+
// ~60 reducer calls instead of 5000. dispose() flushes pending text
|
|
162
|
+
// synchronously on unmount so end-of-stream output isn't dropped.
|
|
163
|
+
const batcher = createChunkBatcher((buffered) => dispatch({ chunk: buffered }));
|
|
164
|
+
const handler = (chunk) => batcher.push(chunk);
|
|
109
165
|
chunkEmitter.on('chunk', handler);
|
|
110
|
-
return () => {
|
|
166
|
+
return () => {
|
|
167
|
+
chunkEmitter.off('chunk', handler);
|
|
168
|
+
batcher.dispose();
|
|
169
|
+
};
|
|
111
170
|
}, [chunkEmitter]);
|
|
112
171
|
// <Static> takes the nodes array; on each render it appends ONLY new
|
|
113
172
|
// items since the last render. Already-rendered items are not
|
|
@@ -2,15 +2,42 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState, useEffect, useMemo } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import TextInput from 'ink-text-input';
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { ALL_SLASH_ENTRIES } from '../repl/slash-picker.js';
|
|
6
|
+
const VISIBLE_ROWS = 8;
|
|
7
|
+
// Longest command is /abap-transport-analysis (24) — fixed column keeps
|
|
8
|
+
// every row single-line; the outer wrap="truncate-end" clips descriptions
|
|
9
|
+
// at the terminal edge instead of wrapping into multi-line soup.
|
|
10
|
+
const NAME_COL = 26;
|
|
11
|
+
/**
|
|
12
|
+
* Deterministic CC-style ranking (2026-06-06 — replaced Fuse): prefix
|
|
13
|
+
* matches first, then substring matches, each group alphabetical. A
|
|
14
|
+
* query like `abap-` lists everything in order instead of fuzzy-score
|
|
15
|
+
* noise; `rada` still surfaces /abap-radar via the substring group.
|
|
16
|
+
*/
|
|
17
|
+
export function rankCommandMatches(query) {
|
|
18
|
+
const q = query.trim().toLowerCase().replace(/^\//, '');
|
|
19
|
+
if (q.length === 0)
|
|
20
|
+
return [...ALL_SLASH_ENTRIES];
|
|
21
|
+
const starts = [];
|
|
22
|
+
const contains = [];
|
|
23
|
+
for (const e of ALL_SLASH_ENTRIES) {
|
|
24
|
+
const bare = e.name.slice(1).toLowerCase();
|
|
25
|
+
if (bare.startsWith(q))
|
|
26
|
+
starts.push(e);
|
|
27
|
+
else if (bare.includes(q))
|
|
28
|
+
contains.push(e);
|
|
29
|
+
}
|
|
30
|
+
return [...starts, ...contains];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* CC-style command menu (2026-06-06 rewrite): borderless single-line
|
|
34
|
+
* rows under the filter input, full result list navigable via a
|
|
35
|
+
* scrolling window — the cursor is never clamped to the first page.
|
|
36
|
+
*/
|
|
37
|
+
export function CommandPalette({ initialQuery, onSelect, onCancel }) {
|
|
38
|
+
const [query, setQuery] = useState(initialQuery ?? '');
|
|
9
39
|
const [cursor, setCursor] = useState(0);
|
|
10
|
-
const
|
|
11
|
-
const results = useMemo(() => query
|
|
12
|
-
? fuse.search(query).slice(0, 8).map((r) => r.item)
|
|
13
|
-
: SKILL_CATALOG.slice(0, 8), [query, fuse]);
|
|
40
|
+
const results = useMemo(() => rankCommandMatches(query), [query]);
|
|
14
41
|
useEffect(() => { setCursor(0); }, [query]);
|
|
15
42
|
useInput((_input, key) => {
|
|
16
43
|
if (key.escape) {
|
|
@@ -26,9 +53,18 @@ export function CommandPalette({ onSelect, onCancel }) {
|
|
|
26
53
|
return;
|
|
27
54
|
}
|
|
28
55
|
if (key.return && results[cursor]) {
|
|
29
|
-
onSelect(
|
|
56
|
+
onSelect(results[cursor].name);
|
|
30
57
|
return;
|
|
31
58
|
}
|
|
32
59
|
});
|
|
33
|
-
|
|
60
|
+
// Scrolling window: keep the cursor ~3 rows from the top while there is
|
|
61
|
+
// more below, like CC's menu.
|
|
62
|
+
const windowStart = Math.max(0, Math.min(cursor - 3, results.length - VISIBLE_ROWS));
|
|
63
|
+
const visible = results.slice(windowStart, windowStart + VISIBLE_ROWS);
|
|
64
|
+
const below = Math.max(0, results.length - (windowStart + VISIBLE_ROWS));
|
|
65
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "/ " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to filter commands" })] }), results.length === 0 && (_jsx(Text, { dimColor: true, children: " no matching command \u2014 edit the filter or Esc to cancel" })), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((e, i) => {
|
|
66
|
+
const idx = windowStart + i;
|
|
67
|
+
const sel = idx === cursor;
|
|
68
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: sel ? 'cyan' : undefined, bold: sel, children: [sel ? '▸ ' : ' ', e.name.padEnd(NAME_COL)] }), _jsx(Text, { dimColor: true, children: e.description })] }, e.name));
|
|
69
|
+
}), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" })] }));
|
|
34
70
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import { filterFileChoices } from '../repl/at-picker.js';
|
|
6
|
+
const VISIBLE_ROWS = 8;
|
|
7
|
+
/**
|
|
8
|
+
* CC-style workspace-file menu (2026-06-06, matches CommandPalette):
|
|
9
|
+
* borderless single-line rows, substring filtering (filenames are
|
|
10
|
+
* structured ids — fuzzy would surface lookalike envelope versions),
|
|
11
|
+
* full result list navigable via a scrolling window.
|
|
12
|
+
*/
|
|
13
|
+
export function FilePalette({ files, initialQuery, onSelect, onCancel }) {
|
|
14
|
+
const [query, setQuery] = useState(initialQuery ?? '');
|
|
15
|
+
const [cursor, setCursor] = useState(0);
|
|
16
|
+
const results = useMemo(() => filterFileChoices(query, files), [query, files]);
|
|
17
|
+
useEffect(() => { setCursor(0); }, [query]);
|
|
18
|
+
useInput((_input, key) => {
|
|
19
|
+
if (key.escape) {
|
|
20
|
+
onCancel();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (key.upArrow) {
|
|
24
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (key.downArrow) {
|
|
28
|
+
setCursor((c) => Math.min(results.length - 1, c + 1));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (key.return && results[cursor]) {
|
|
32
|
+
onSelect(results[cursor].filename);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const windowStart = Math.max(0, Math.min(cursor - 3, results.length - VISIBLE_ROWS));
|
|
37
|
+
const visible = results.slice(windowStart, windowStart + VISIBLE_ROWS);
|
|
38
|
+
const below = Math.max(0, results.length - (windowStart + VISIBLE_ROWS));
|
|
39
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "@ " }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "type to filter workspace files" })] }), results.length === 0 && (_jsx(Text, { dimColor: true, children: " no matching file \u2014 edit the filter or Esc to cancel" })), windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), visible.map((f, i) => {
|
|
40
|
+
const idx = windowStart + i;
|
|
41
|
+
const sel = idx === cursor;
|
|
42
|
+
return (_jsx(Text, { wrap: "truncate-end", children: _jsxs(Text, { color: sel ? 'cyan' : undefined, bold: sel, children: [sel ? '▸ ' : ' ', f.filename] }) }, f.filename));
|
|
43
|
+
}), below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] }), _jsx(Text, { dimColor: true, children: " \u2191\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" })] }));
|
|
44
|
+
}
|
package/dist/ui/footer.js
CHANGED
|
@@ -24,7 +24,7 @@ function appendHistory(line, history) {
|
|
|
24
24
|
history.push(line);
|
|
25
25
|
}
|
|
26
26
|
const PEACH = '#FFCBA4';
|
|
27
|
-
export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange,
|
|
27
|
+
export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled }) {
|
|
28
28
|
const [value, setValue] = useState('');
|
|
29
29
|
const [history] = useState(loadHistory);
|
|
30
30
|
const [histIdx, setHistIdx] = useState(-1);
|
|
@@ -34,11 +34,14 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
|
|
|
34
34
|
const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
|
|
35
35
|
// Mirror state into parent when it cares (e.g. App's ? key-binding gate).
|
|
36
36
|
useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
|
|
37
|
-
// If the parent pushes a palette
|
|
37
|
+
// If the parent pushes a prefill (palette pick, palette cancel restore,
|
|
38
|
+
// empty-body guard), pre-fill our input. Keyed on the nonce so identical
|
|
39
|
+
// text can re-fire.
|
|
38
40
|
useEffect(() => {
|
|
39
|
-
if (
|
|
40
|
-
setValue(
|
|
41
|
-
|
|
41
|
+
if (prefill && prefill.text.length > 0)
|
|
42
|
+
setValue(prefill.text);
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, [prefill?.nonce]);
|
|
42
45
|
useEffect(() => {
|
|
43
46
|
if (fuzzyMode) {
|
|
44
47
|
setFuzzyResults(fuse.search(fuzzyQuery).slice(0, 8).map((r) => r.item));
|
|
@@ -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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"adt:smoke": "tsx scripts/adt-smoke.ts",
|
|
41
41
|
"smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
|
|
42
42
|
"zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
|
|
43
|
+
"bench:models": "tsx scripts/bench-models.ts",
|
|
43
44
|
"prepare": "husky"
|
|
44
45
|
},
|
|
45
46
|
"engines": {
|
|
@@ -81,6 +82,9 @@
|
|
|
81
82
|
"vitest": "^2.1.0"
|
|
82
83
|
},
|
|
83
84
|
"pnpm": {
|
|
84
|
-
"onlyBuiltDependencies": [
|
|
85
|
+
"onlyBuiltDependencies": [
|
|
86
|
+
"keytar",
|
|
87
|
+
"esbuild"
|
|
88
|
+
]
|
|
85
89
|
}
|
|
86
90
|
}
|