@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.
Files changed (50) hide show
  1. package/bench/README.md +78 -0
  2. package/bench/prompts/abap-document-cds.md +44 -0
  3. package/bench/prompts/abap-explain-bdef-handler.md +57 -0
  4. package/bench/prompts/abap-test-method.md +42 -0
  5. package/bench/results/abap-document-cds/claude-haiku-4-5.md +189 -0
  6. package/bench/results/abap-document-cds/claude-opus-4-7.md +120 -0
  7. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +151 -0
  8. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +112 -0
  9. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +101 -0
  10. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +101 -0
  11. package/bench/results/abap-test-method/claude-haiku-4-5.md +186 -0
  12. package/bench/results/abap-test-method/claude-opus-4-7.md +193 -0
  13. package/bench/results/abap-test-method/claude-sonnet-4-6.md +234 -0
  14. package/dist/agent/loop.js +144 -26
  15. package/dist/agent/steering-queue.js +27 -0
  16. package/dist/approvals/jwt.js +45 -5
  17. package/dist/approvals/render.js +38 -0
  18. package/dist/classifier/client.js +6 -2
  19. package/dist/commands/plan-resume.js +308 -0
  20. package/dist/config/loader.js +13 -6
  21. package/dist/cost/pricing.js +14 -5
  22. package/dist/one-shot.js +6 -0
  23. package/dist/projects/email-template.js +2 -0
  24. package/dist/projects/extract-plan.js +85 -0
  25. package/dist/projects/index.js +3 -0
  26. package/dist/projects/plan-run.js +120 -0
  27. package/dist/projects/plan-schema.js +150 -0
  28. package/dist/projects/promote-command.js +1 -0
  29. package/dist/projects/save-command.js +17 -1
  30. package/dist/projects/status.js +20 -0
  31. package/dist/projects/validate.js +2 -0
  32. package/dist/renderer/question-normalizer.js +16 -6
  33. package/dist/renderer/syntax.js +16 -1
  34. package/dist/renderer/thinking-heartbeat.js +13 -1
  35. package/dist/repl/at-picker.js +39 -20
  36. package/dist/repl/slash-picker.js +15 -4
  37. package/dist/repl.js +216 -17
  38. package/dist/router/classifier.js +74 -8
  39. package/dist/skill-catalog.js +8 -2
  40. package/dist/tools/subagent/agent_run.js +12 -0
  41. package/dist/ui/app.js +80 -11
  42. package/dist/ui/body.js +61 -2
  43. package/dist/ui/command-palette.js +46 -10
  44. package/dist/ui/file-palette.js +44 -0
  45. package/dist/ui/footer.js +8 -5
  46. package/dist/ui/line-resolution.js +81 -0
  47. package/dist/ui/turn-status-emitter.js +52 -0
  48. package/dist/ui/turn-status.js +59 -0
  49. package/dist/ui/widgets/ask-question-modal.js +26 -1
  50. 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
- const [showPalette, setShowPalette] = useState(false);
37
- const [paletteResult, setPaletteResult] = useState('');
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: !showPalette && !showTimeline && !pendingApproval && !coachingPending });
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
- setShowPalette(true);
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: onSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, paletteResult: paletteResult, onValueChange: setFooterValue,
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
- || showPalette
114
- || showTimeline }), showPalette && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { onSelect: (s) => {
115
- setPaletteResult(s);
116
- setShowPalette(false);
117
- }, onCancel: () => setShowPalette(false) }) })), 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: () => {
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
- const handler = (chunk) => dispatch({ chunk });
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 () => { chunkEmitter.off('chunk', handler); };
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 Fuse from 'fuse.js';
6
- import { SKILL_CATALOG } from '../skill-catalog.js';
7
- export function CommandPalette({ onSelect, onCancel }) {
8
- const [query, setQuery] = useState('');
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 fuse = useMemo(() => new Fuse(SKILL_CATALOG, { keys: ['name', 'description'], threshold: 0.4 }), []);
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(`/${results[cursor].name} `);
56
+ onSelect(results[cursor].name);
30
57
  return;
31
58
  }
32
59
  });
33
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 60, children: [_jsx(Text, { bold: true, color: "cyan", children: "Command palette" }), _jsx(TextInput, { value: query, onChange: setQuery, placeholder: "Type to filter..." }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: results.map((skill, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: i === cursor ? 'cyan' : 'white', bold: i === cursor, children: [i === cursor ? '\u25B8' : ' ', " /", skill.name.padEnd(20)] }), _jsx(Text, { dimColor: true, children: skill.description })] }, skill.name))) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter: insert Esc: cancel" }) })] }));
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, paletteResult, disabled }) {
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 result, pre-fill our input.
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 (paletteResult && paletteResult.length > 0)
40
- setValue(paletteResult);
41
- }, [paletteResult]);
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.7.1",
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": ["keytar", "esbuild"]
85
+ "onlyBuiltDependencies": [
86
+ "keytar",
87
+ "esbuild"
88
+ ]
85
89
  }
86
90
  }