@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.
@@ -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.8.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": ["keytar", "esbuild"]
85
+ "onlyBuiltDependencies": [
86
+ "keytar",
87
+ "esbuild"
88
+ ]
86
89
  }
87
90
  }