@cspeach/cli 0.6.5 → 0.7.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 (47) hide show
  1. package/dist/agent/intent-system-prompt.js +46 -0
  2. package/dist/agent/loop.js +180 -18
  3. package/dist/agent/parallel-write-guard.js +71 -0
  4. package/dist/agent/repair-partial.js +88 -1
  5. package/dist/agent/retry-cap.js +121 -0
  6. package/dist/agent/summarise-via-provider.js +51 -0
  7. package/dist/approvals/jwt.js +33 -12
  8. package/dist/commands/auto-compact.js +94 -0
  9. package/dist/commands/compact.js +265 -0
  10. package/dist/commands/cost.js +56 -0
  11. package/dist/commands/help.js +21 -14
  12. package/dist/config/loader.js +39 -0
  13. package/dist/cost/cost-log.js +113 -0
  14. package/dist/cost/pricing.js +91 -0
  15. package/dist/one-shot.js +1 -1
  16. package/dist/renderer/markdown.js +7 -1
  17. package/dist/renderer/status-footer.js +37 -14
  18. package/dist/renderer/thinking-heartbeat.js +15 -3
  19. package/dist/renderer/tool-widget.js +6 -1
  20. package/dist/renderer/tty.js +27 -0
  21. package/dist/repl/cspeach-shell-detect.js +33 -0
  22. package/dist/repl/post-turn-status.js +68 -0
  23. package/dist/repl/slash-completer.js +2 -0
  24. package/dist/repl/slash-picker.js +2 -0
  25. package/dist/repl.js +371 -46
  26. package/dist/sap-errors/parse-adt-exception.js +149 -0
  27. package/dist/session/schema.js +2 -1
  28. package/dist/session/store.js +19 -0
  29. package/dist/tools/_filesystem-shared.js +9 -1
  30. package/dist/tools/ask-question.js +19 -0
  31. package/dist/tools/sap-read.js +56 -4
  32. package/dist/tools/sap-write.js +19 -0
  33. package/dist/tools/subagent/schedule_draft_create.js +137 -0
  34. package/dist/ui/alt-screen.js +120 -0
  35. package/dist/ui/app.js +39 -15
  36. package/dist/ui/ask-question-emitter.js +13 -0
  37. package/dist/ui/body.js +39 -23
  38. package/dist/ui/coaching-picker-classic.js +8 -1
  39. package/dist/ui/footer.js +6 -1
  40. package/dist/ui/header.js +19 -4
  41. package/dist/ui/sap-state-store.js +13 -1
  42. package/dist/ui/sidebar.js +5 -3
  43. package/dist/ui/status-emitter.js +19 -0
  44. package/dist/ui/status-row.js +30 -4
  45. package/dist/ui/widgets/ask-question-modal.js +132 -0
  46. package/dist/ui/widgets/coaching-picker.js +55 -8
  47. package/package.json +1 -1
package/dist/ui/app.js CHANGED
@@ -2,8 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
3
  import { Box, useInput } from 'ink';
4
4
  import crypto from 'node:crypto';
5
- import { Header } from './header.js';
6
- import { Sidebar } from './sidebar.js';
7
5
  import { Body } from './body.js';
8
6
  import { Footer } from './footer.js';
9
7
  import { ApprovalModal } from './approval-modal.js';
@@ -15,17 +13,29 @@ import { errorEmitter } from './error-emitter.js';
15
13
  import { SapStateContext, globalStore } from './sap-state-store.js';
16
14
  import { coachingPickerEmitter } from './coaching-picker-emitter.js';
17
15
  import { CoachingPicker } from './widgets/coaching-picker.js';
18
- export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, writeMode }) {
19
- // Approval-modal overlay state (H-new-4). Populated by approvalEmitter
20
- // subscription; rendered as the last child so it overlays correctly.
16
+ import { askQuestionEmitter } from './ask-question-emitter.js';
17
+ import { AskQuestionModal } from './widgets/ask-question-modal.js';
18
+ /**
19
+ * App — inline-mode Ink frame (Phase D6, 2026-05-17). After the
20
+ * alt-screen detour, reverted to CC's architecture:
21
+ *
22
+ * <Header /> ← renders once, becomes scrollback
23
+ * <Body chunkEmitter=… /> ← <Static> commits each node, becomes scrollback
24
+ * {coachingPending && …} ← inline overlays that mount above input
25
+ * {askQuestionPending && …}
26
+ * <Footer /> ← dynamic, re-renders at cursor (sticky-ish)
27
+ *
28
+ * No alt-screen, no termHeight, no PgUp scroll state. The terminal's
29
+ * native scrollback handles all of it — mouse wheel, PgUp, search,
30
+ * everything just works because we render inline like a normal CLI.
31
+ */
32
+ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger, getToolCalls, writeMode, onInterrupt }) {
21
33
  const [pendingApproval, setPendingApproval] = useState(null);
22
34
  const [coachingPending, setCoachingPending] = useState(null);
23
- // Task 25: slash palette overlay state.
35
+ const [askQuestionPending, setAskQuestionPending] = useState(null);
24
36
  const [showPalette, setShowPalette] = useState(false);
25
37
  const [paletteResult, setPaletteResult] = useState('');
26
- // Task 27: error stack overlay state.
27
38
  const [errorStack, setErrorStack] = useState([]);
28
- // Task 29: session timeline overlay state.
29
39
  const [showTimeline, setShowTimeline] = useState(false);
30
40
  const [footerValue, setFooterValue] = useState('');
31
41
  useEffect(() => {
@@ -44,6 +54,13 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
44
54
  coachingPickerEmitter.off('request', onRequest);
45
55
  };
46
56
  }, []);
57
+ useEffect(() => {
58
+ const onRequest = (req) => {
59
+ setAskQuestionPending(req);
60
+ };
61
+ askQuestionEmitter.on('request', onRequest);
62
+ return () => { askQuestionEmitter.off('request', onRequest); };
63
+ }, []);
47
64
  useEffect(() => {
48
65
  const onError = (err) => {
49
66
  const id = crypto.randomUUID();
@@ -56,12 +73,16 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
56
73
  return () => { errorEmitter.off('error', onError); };
57
74
  }, []);
58
75
  // `?` key opens the timeline when the footer is empty and no other overlay
59
- // is active. Gating ensures TextInput captures `?` as a literal character
60
- // when the user is typing.
76
+ // is active.
61
77
  useInput((input, _key) => {
62
78
  if (input === '?' && footerValue === '')
63
79
  setShowTimeline(true);
64
80
  }, { isActive: !showPalette && !showTimeline && !pendingApproval && !coachingPending });
81
+ // Ctrl+C handler — abort in-flight turn or exit at idle.
82
+ useInput((input, key) => {
83
+ if (key.ctrl && input === 'c')
84
+ onInterrupt?.();
85
+ }, { isActive: true });
65
86
  const clearApproval = () => setPendingApproval(null);
66
87
  const handleSlashTrigger = () => {
67
88
  setShowPalette(true);
@@ -76,13 +97,16 @@ export function App({ chunkEmitter, onSubmit, onSlashTrigger, onHistoryTrigger,
76
97
  .filter((c) => c.completed === true)
77
98
  .map(toTimelineEntry)
78
99
  : [];
79
- return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(Header, { writeMode: writeMode }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Body, { chunkEmitter: chunkEmitter }), _jsx(Sidebar, {})] }), _jsx(Footer, { onSubmit: onSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, paletteResult: paletteResult, onValueChange: setFooterValue }), showPalette && (_jsx(Box, { position: "absolute", marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { onSelect: (s) => {
100
+ return (_jsx(SapStateContext.Provider, { value: globalStore, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Body, { chunkEmitter: chunkEmitter }), coachingPending && (_jsx(CoachingPicker, { params: coachingPending.params, onResolve: (result) => {
101
+ coachingPending.resolve(result);
102
+ setCoachingPending(null);
103
+ } })), askQuestionPending && (_jsx(AskQuestionModal, { params: askQuestionPending, onResolve: (result) => {
104
+ askQuestionPending.resolve(result);
105
+ setAskQuestionPending(null);
106
+ } })), _jsx(Footer, { onSubmit: onSubmit, onSlashTrigger: handleSlashTrigger, onHistoryTrigger: handleHistoryTrigger, paletteResult: paletteResult, onValueChange: setFooterValue }), showPalette && (_jsx(Box, { marginTop: 1, marginLeft: 2, children: _jsx(CommandPalette, { onSelect: (s) => {
80
107
  setPaletteResult(s);
81
108
  setShowPalette(false);
82
- }, onCancel: () => setShowPalette(false) }) })), errorStack.map((e, i) => (_jsx(Box, { position: "absolute", marginTop: i * 6, children: _jsx(RichErrorBox, { code: e.code, message: e.body, hint: e.hint, severity: "error" }) }, e.id))), showTimeline && (_jsx(Box, { position: "absolute", marginTop: 1, marginLeft: 2, children: _jsx(SessionTimeline, { entries: timelineEntries, onClose: () => setShowTimeline(false) }) })), coachingPending && (_jsx(Box, { position: "absolute", marginTop: 2, marginLeft: 2, children: _jsx(CoachingPicker, { params: coachingPending.params, onResolve: (result) => {
83
- coachingPending.resolve(result);
84
- setCoachingPending(null);
85
- } }) })), pendingApproval && (_jsx(Box, { position: "absolute", marginTop: 2, marginLeft: 2, children: _jsx(ApprovalModal, { change: pendingApproval.change, effectiveRisk: pendingApproval.effectiveRisk, transport: pendingApproval.transport, onApply: () => {
109
+ }, 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: () => {
86
110
  pendingApproval.resolve({ approved: true });
87
111
  clearApproval();
88
112
  }, onSkip: () => {
@@ -0,0 +1,13 @@
1
+ import { EventEmitter } from 'node:events';
2
+ class AskQuestionEmitter extends EventEmitter {
3
+ request(params) {
4
+ return new Promise((resolve) => {
5
+ if (this.listenerCount('request') === 0) {
6
+ resolve({ answer: null, cancelled: true });
7
+ return;
8
+ }
9
+ this.emit('request', { ...params, resolve });
10
+ });
11
+ }
12
+ }
13
+ export const askQuestionEmitter = new AskQuestionEmitter();
package/dist/ui/body.js CHANGED
@@ -1,40 +1,31 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { useReducer, useEffect } from 'react';
3
- import { Box, Text } from 'ink';
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
- // WIDGET_CONTROL is the longer sentinel (`\x00CSPEACH-WIDGET-V3\x00`) — see M2.
6
+ // WIDGET_CONTROL is the longer sentinel (`\x00CSPEACH-WIDGET-V3\x00`).
7
7
  // RegExp escaping: `\x00` works in JS regex source directly.
8
8
  const WIDGET_RE = new RegExp(WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
9
9
  '(\\d+):(\\w[\\w-]*):([\\w+/=]+)' +
10
10
  WIDGET_CONTROL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
11
- // Eviction caps (H-new-1).
11
+ // Eviction caps (H-new-1) — bound React state size on long sessions.
12
12
  const MAX_NODES = 500;
13
13
  const MAX_TEXT_LEN = 100_000;
14
14
  export const INITIAL_BODY_STATE = { nodes: [], pending: '', nextId: 0, totalTextLen: 0 };
15
- /**
16
- * Parse an incremental chunk into a list of new nodes + any unterminated tail
17
- * that should stay in `pending`. Only the NEW content (pending + chunk) is
18
- * scanned — previously-parsed nodes are never re-regexed.
19
- */
20
15
  function parseIncremental(pending, chunk) {
21
16
  const combined = pending + chunk;
22
- // If an unterminated WIDGET_CONTROL appears at the tail, defer from that point.
23
17
  const lastOpenerIdx = combined.lastIndexOf(WIDGET_CONTROL);
24
18
  let scannable = combined;
25
19
  let nextPending = '';
26
20
  if (lastOpenerIdx !== -1) {
27
- // Check whether there's a matching closer after it.
28
21
  const afterOpener = combined.slice(lastOpenerIdx + WIDGET_CONTROL.length);
29
22
  if (!afterOpener.includes(WIDGET_CONTROL)) {
30
- // Unterminated — buffer from the opener onwards.
31
23
  scannable = combined.slice(0, lastOpenerIdx);
32
24
  nextPending = combined.slice(lastOpenerIdx);
33
25
  }
34
26
  }
35
27
  const newNodes = [];
36
28
  const parts = scannable.split(WIDGET_RE);
37
- // split with 3 groups produces 4 slots per widget: [text, idx, type, b64, text, ...]
38
29
  for (let i = 0; i < parts.length; i++) {
39
30
  const mod = i % 4;
40
31
  if (mod === 0) {
@@ -56,7 +47,7 @@ function parseIncremental(pending, chunk) {
56
47
  catch {
57
48
  newNodes.push({ kind: 'widget-error' });
58
49
  }
59
- i += 2; // skip type + b64; loop's i++ advances past last captured group
50
+ i += 2;
60
51
  }
61
52
  }
62
53
  return { newNodes, nextPending };
@@ -72,7 +63,6 @@ export function bodyReducer(state, action) {
72
63
  return withId;
73
64
  });
74
65
  let nodes = state.nodes.concat(appended);
75
- // Sliding-window eviction: drop oldest nodes until both caps satisfied.
76
66
  while (nodes.length > MAX_NODES || totalTextLen > MAX_TEXT_LEN) {
77
67
  const [evicted, ...rest] = nodes;
78
68
  if (!evicted)
@@ -83,6 +73,35 @@ export function bodyReducer(state, action) {
83
73
  }
84
74
  return { nodes, pending: nextPending, nextId: id, totalTextLen };
85
75
  }
76
+ function renderNode(n) {
77
+ if (n.kind === 'text')
78
+ return _jsx(Text, { children: n.value }, n.id);
79
+ if (n.kind === 'widget') {
80
+ const Comp = WIDGET_COMPONENTS[n.widgetType];
81
+ return _jsx(Comp, { data: n.data }, n.id);
82
+ }
83
+ return _jsx(Text, { color: "yellow", children: "\u26A0 widget render error" }, n.id);
84
+ }
85
+ /**
86
+ * Inline-mode Body (Phase D6, 2026-05-17) — back to CC's architecture
87
+ * after the alt-screen detour was rejected.
88
+ *
89
+ * We render in INLINE mode (no alt-screen takeover). Ink renders to the
90
+ * normal terminal stream and the terminal's native scrollback handles
91
+ * mouse wheel, PgUp, search, etc. Two regions:
92
+ *
93
+ * - <Static items={nodes}>: each committed node renders ONCE, then
94
+ * becomes part of the terminal's scrollback buffer. Ink never
95
+ * redraws Static items, so they don't flicker, can't be clipped,
96
+ * and the user can scroll back through them naturally.
97
+ * - Dynamic tail (intentionally empty here): the Footer below is
98
+ * the dynamic region — its re-renders happen at the cursor's
99
+ * bottom position via cursor-up + clear-line.
100
+ *
101
+ * The previous attempt used <Static> in alt-screen mode where Static's
102
+ * "commit to scrollback" semantic broke (alt-screen has no scrollback).
103
+ * In inline mode <Static> is exactly the right primitive.
104
+ */
86
105
  export const Body = React.memo(function Body({ chunkEmitter }) {
87
106
  const [state, dispatch] = useReducer(bodyReducer, INITIAL_BODY_STATE);
88
107
  useEffect(() => {
@@ -90,13 +109,10 @@ export const Body = React.memo(function Body({ chunkEmitter }) {
90
109
  chunkEmitter.on('chunk', handler);
91
110
  return () => { chunkEmitter.off('chunk', handler); };
92
111
  }, [chunkEmitter]);
93
- return (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: state.nodes.map((n) => {
94
- if (n.kind === 'text')
95
- return _jsx(Text, { children: n.value }, n.id);
96
- if (n.kind === 'widget') {
97
- const Comp = WIDGET_COMPONENTS[n.widgetType];
98
- return _jsx(Comp, { data: n.data }, n.id);
99
- }
100
- return _jsx(Text, { color: "yellow", children: "\\u26A0 widget render error" }, n.id);
101
- }) }));
112
+ // <Static> takes the nodes array; on each render it appends ONLY new
113
+ // items since the last render. Already-rendered items are not
114
+ // re-painted — they become part of the terminal's scrollback. This
115
+ // is what makes Ink behave like a regular terminal program (CC, npm,
116
+ // tsc, etc.) in inline mode.
117
+ return (_jsx(Static, { items: state.nodes, children: (n) => renderNode(n) }));
102
118
  });
@@ -20,7 +20,14 @@ export async function coachingPickerClassic(params) {
20
20
  value: CANCEL_VALUE,
21
21
  });
22
22
  const message = `I'm not sure which skill fits for: "${params.prompt.slice(0, 50)}${params.prompt.length > 50 ? '...' : ''}". Pick one:`;
23
- const defaultValue = params.preSelect && params.options.length > 0 ? params.options[0].skill : undefined;
23
+ // 2026-05-17 — preferSkill (the just-used skill) takes precedence over
24
+ // preSelect when present AND in options, so a follow-up question can be
25
+ // confirmed with a single Enter press.
26
+ const preferred = params.preferSkill
27
+ ? params.options.find((o) => o.skill === params.preferSkill)
28
+ : undefined;
29
+ const defaultValue = preferred?.skill
30
+ ?? (params.preSelect && params.options.length > 0 ? params.options[0].skill : undefined);
24
31
  try {
25
32
  const chosen = await select({ message, choices, default: defaultValue });
26
33
  if (chosen === CANCEL_VALUE)
package/dist/ui/footer.js CHANGED
@@ -99,5 +99,10 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
99
99
  if (fuzzyMode) {
100
100
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search..." }), fuzzyResults.map((r, i) => _jsx(Text, { dimColor: i > 0, children: r }, i)), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
101
101
  }
102
- return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })] }));
102
+ return (
103
+ // Phase D6 (2026-05-17) — borderless input. The border had pushed
104
+ // the terminal cursor one line below the input (the "booger"); now
105
+ // both the terminal cursor AND TextInput's block character land at
106
+ // the same position, appearing as a single blinking cursor.
107
+ _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })] }));
103
108
  }
package/dist/ui/header.js CHANGED
@@ -1,17 +1,32 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { useSapState } from './sap-state-store.js';
4
+ import { CLI_VERSION } from '../lib/version.js';
4
5
  const PEACH = '#FFCBA4';
6
+ /**
7
+ * Single-line rich header (Phase D3, 2026-05-16) — back to one line per
8
+ * Laeeq's feedback that the two-line variant felt worse. Uses tighter
9
+ * separators and shorter labels so it fits cleanly on typical terminals
10
+ * (100+ cols). On a narrower terminal Ink will wrap rather than truncate,
11
+ * accepted as a degradation case.
12
+ *
13
+ * Layout:
14
+ * 🍑 CSPeach 0.6.5 │ S4H │ S.LAEEQ │ tr none │ 28k tok │ $0.54 │ approval-gated
15
+ */
5
16
  export function Header({ writeMode } = {}) {
6
- const { system } = useSapState();
17
+ const s = useSapState();
7
18
  const isAdvisory = writeMode === 'advisory-only';
8
19
  const modeLabel = isAdvisory
9
- ? 'ADVISORY (writes proposed, never executed)'
20
+ ? 'ADVISORY'
10
21
  : writeMode === 'approval-gated'
11
22
  ? 'approval-gated'
12
23
  : writeMode === 'auto'
13
24
  ? 'auto'
14
25
  : undefined;
15
26
  const modeColor = isAdvisory ? 'yellow' : 'green';
16
- return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach v0.3" }), _jsx(Text, { children: " " }), modeLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " write: " }), _jsx(Text, { color: modeColor, bold: isAdvisory, children: modeLabel })] })), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: "cyan", children: system })] }));
27
+ const tokDisplay = s.tokenUsageToday >= 1000
28
+ ? `${(s.tokenUsageToday / 1000).toFixed(1)}k tok`
29
+ : `${s.tokenUsageToday} tok`;
30
+ const spendDisplay = `$${s.dollarSpendToday.toFixed(2)}`;
31
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, color: PEACH, children: ["\uD83C\uDF51 CSPeach ", CLI_VERSION] }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "cyan", children: s.system || 'no system' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { color: "gray", children: " \u2502 tr " }), _jsx(Text, { color: "yellow", children: s.transport ?? 'none' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: tokDisplay }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay }), modeLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: modeColor, bold: isAdvisory, children: modeLabel })] }))] }));
17
32
  }
@@ -1,7 +1,19 @@
1
1
  import React, { createContext, useContext } from 'react';
2
+ /**
3
+ * Phase A #3 (2026-05-16) — `dollarCap` was hard-coded to 10. Live evidence
4
+ * shows real sessions hit $50-150 routinely (one /abap-radar + 30 explain
5
+ * turns landed at $167 today). A $10 cap turned the sidebar spend bar
6
+ * red within the first minute of every session, training users to
7
+ * ignore the colour signal. New default 100; the cap is purely a UI
8
+ * coloring threshold — there is no actual enforcement.
9
+ *
10
+ * Future: thread from a [sidebar] config block so power users can lower
11
+ * (tight budget) or raise (heavy build day) the threshold. Deferred to
12
+ * Phase B / C — see docs/2026-05-16-ink-polish-research.md.
13
+ */
2
14
  const DEFAULT_STATE = {
3
15
  system: '', user: '', transport: null, locksHeld: [],
4
- tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 10, recentTurns: [],
16
+ tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 100, recentTurns: [],
5
17
  };
6
18
  /**
7
19
  * Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
@@ -3,8 +3,10 @@ import { Box, Text } from 'ink';
3
3
  import { useSapState } from './sap-state-store.js';
4
4
  export function Sidebar() {
5
5
  const s = useSapState();
6
- const spendColor = s.dollarSpendToday > s.dollarCap * 0.9 ? 'red'
7
- : s.dollarSpendToday > s.dollarCap * 0.7 ? 'yellow' : 'green';
6
+ // Phase A** (2026-05-16) — dropped the "/ of $100" pseudo-budget. We
7
+ // don't know the user's actual API spending limit; showing a fake
8
+ // cap trained the colour signal to be meaningless. Now just shows the
9
+ // raw spend. Colour signal removed alongside.
8
10
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, width: 30, children: [_jsx(Text, { bold: true, color: "cyan", children: s.system || 'no system' }), _jsxs(Text, { color: "gray", children: ["user: ", _jsx(Text, { color: "white", children: s.user || '—' })] }), _jsxs(Text, { color: "gray", children: ["transport: ", _jsx(Text, { color: "yellow", children: s.transport ?? 'none' })] }), _jsxs(Text, { color: "gray", children: ["locks: ", s.locksHeld.length === 0 ? _jsx(Text, { dimColor: true, children: "(none)" })
9
- : s.locksHeld.map((l, i) => _jsxs(Text, { color: "red", children: [l, " "] }, i))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: spendColor, children: [s.tokenUsageToday.toLocaleString(), " tok / $", s.dollarSpendToday.toFixed(2), " of $", s.dollarCap] }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "\u2500 recent turns \u2500" }), s.recentTurns.slice(-5).reverse().map((t, i) => (_jsxs(Text, { dimColor: true, children: [t.time, " ", t.command.slice(0, 20)] }, i))), s.recentTurns.length === 0 && _jsx(Text, { dimColor: true, children: "(no turns yet)" })] })] }));
11
+ : s.locksHeld.map((l, i) => _jsxs(Text, { color: "red", children: [l, " "] }, i))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "green", children: [s.tokenUsageToday.toLocaleString(), " tok / $", s.dollarSpendToday.toFixed(2)] }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "\u2500 recent turns \u2500" }), s.recentTurns.slice(-5).reverse().map((t, i) => (_jsxs(Text, { dimColor: true, children: [t.time, " ", t.command.slice(0, 20)] }, i))), s.recentTurns.length === 0 && _jsx(Text, { dimColor: true, children: "(no turns yet)" })] })] }));
10
12
  }
@@ -0,0 +1,19 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /**
3
+ * Singleton channel from `runTurn` (producer) to `<App>` (consumer) for
4
+ * status-row updates in Ink mode. Phase A #1 (2026-05-16) — the prior
5
+ * version of the REPL claimed Ink rendered its own `<StatusRow>` but
6
+ * never imported the component. As a result Ink users saw NO cost,
7
+ * token, or session info ever. This emitter is the missing wire.
8
+ *
9
+ * Producer (repl.tsx, end-of-turn): emits an `update` event with the
10
+ * latest StatusRowData. Consumer (App.tsx): subscribes in a useEffect,
11
+ * stores in state, renders `<StatusRow data={...} />` above `<Footer>`.
12
+ *
13
+ * Why a separate emitter (not chunkEmitter): chunkEmitter pipes strings
14
+ * into Body. StatusRow needs structured data (numbers + dollar amounts)
15
+ * and lives in a fixed region — passing it through chunks would mean
16
+ * sentinel parsing for what is naturally typed state. Matches the
17
+ * approvalEmitter / errorEmitter / coachingPickerEmitter pattern.
18
+ */
19
+ export const statusEmitter = new EventEmitter();
@@ -1,12 +1,38 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { formatCost } from '../cost/pricing.js';
4
+ import { LARGE_SESSION_THRESHOLD } from '../renderer/status-footer.js';
3
5
  /**
4
- * Single-line status row shown inside the Ink frame (above Footer).
5
- * Mirrors the non-Ink chalk-based `printStatusFooter` in renderer/status-footer.ts
6
- * so users see the same fields in both modes.
6
+ * Single-line status row mounted in the Ink frame above `<Footer>`.
7
+ * Mirrors the layout of `printStatusFooter` (renderer/status-footer.ts)
8
+ * so users see the same fields whichever mode they run in.
9
+ *
10
+ * Phase A #1 (2026-05-16): mounted into App.tsx for the first time —
11
+ * the component existed but no one rendered it. Also added the
12
+ * large-session advisory inline so N3 closes with this change.
7
13
  */
8
14
  export function StatusRow({ data }) {
9
15
  if (!data)
10
16
  return _jsx(Box, {});
11
- return (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [data.sessionId, " SAP: ", data.sapAlias, " skill: ", data.skill, " tokens: ", data.tokens, " elapsed: ", data.elapsedMs, "ms"] }) }));
17
+ const shortSession = data.sessionId.slice(0, 8);
18
+ const skillDisplay = data.skill.startsWith('/') ? data.skill : `/${data.skill}`;
19
+ const turns = data.turnCount != null ? `${data.turnCount} turn${data.turnCount === 1 ? '' : 's'}` : '';
20
+ const tokStr = `${data.tokens.toLocaleString()} tok this turn`;
21
+ const cachedStr = data.cachedTokens && data.cachedTokens > 0 ? `${data.cachedTokens.toLocaleString()} cached` : '';
22
+ const elapsedStr = data.elapsedMs > 0 ? `${(data.elapsedMs / 1000).toFixed(1)}s` : '';
23
+ const costTurnStr = data.costThisTurn != null ? formatCost(data.costThisTurn) : '';
24
+ const costSessStr = data.costSession != null ? `session: ${formatCost(data.costSession)}` : '';
25
+ const parts = [
26
+ `session ${shortSession}`,
27
+ data.sapAlias,
28
+ skillDisplay,
29
+ ...(turns ? [turns] : []),
30
+ tokStr,
31
+ ...(costTurnStr ? [costTurnStr] : []),
32
+ ...(cachedStr ? [cachedStr] : []),
33
+ ...(costSessStr ? [costSessStr] : []),
34
+ ...(elapsedStr ? [elapsedStr] : []),
35
+ ];
36
+ const isLarge = data.sessionInputTokens != null && data.sessionInputTokens >= LARGE_SESSION_THRESHOLD;
37
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: parts.join(' │ ') }), isLarge && (_jsxs(Text, { color: "yellow", children: ["\u26A0 session getting large (", data.sessionInputTokens.toLocaleString(), " input tokens)", ' ', "\u2014 type /compact to summarise older turns, or /exit + cspeach (fresh) for a new topic"] }))] }));
12
38
  }
@@ -0,0 +1,132 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * AskQuestionModal — Ink-native interactive form for the ask_question tool.
4
+ *
5
+ * Critical #2 fix (2026-05-17): the prior path called inquirer.input/
6
+ * select/checkbox directly. In Ink that draws raw to stdout, corrupts
7
+ * the React frame, and the user often can't tell what's being asked.
8
+ *
9
+ * Three modes match the tool's three kinds:
10
+ * - text → free-form TextInput, Enter to submit, Esc to cancel
11
+ * - choice → arrow-key single-select list (mirrors CoachingPicker)
12
+ * - multi → space-to-toggle checkbox list, Enter to submit
13
+ *
14
+ * Mounted inline by App.tsx (NOT as position="absolute" overlay — we
15
+ * learned the hard way that absolute overlays paint over Body and
16
+ * can't be navigated cleanly).
17
+ */
18
+ import { useState } from 'react';
19
+ import { Box, Text, useInput } from 'ink';
20
+ import TextInput from 'ink-text-input';
21
+ const PEACH = '#FFCBA4';
22
+ export function AskQuestionModal({ params, onResolve }) {
23
+ if (params.kind === 'text' || params.choices.length === 0) {
24
+ return _jsx(TextInputForm, { params: params, onResolve: onResolve });
25
+ }
26
+ if (params.kind === 'choice') {
27
+ return _jsx(ChoiceForm, { params: params, onResolve: onResolve });
28
+ }
29
+ return _jsx(MultiForm, { params: params, onResolve: onResolve });
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // Free-form text
33
+ // ---------------------------------------------------------------------------
34
+ function TextInputForm({ params, onResolve }) {
35
+ const [value, setValue] = useState('');
36
+ useInput((_input, key) => {
37
+ if (key.escape) {
38
+ onResolve({ answer: null, cancelled: true });
39
+ }
40
+ }, { isActive: true });
41
+ return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: PEACH, children: '> ' }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: (v) => {
42
+ const trimmed = v.trim();
43
+ if (trimmed.length === 0) {
44
+ onResolve({ answer: null, cancelled: true });
45
+ }
46
+ else {
47
+ onResolve({ answer: trimmed });
48
+ }
49
+ } })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter to submit \u00B7 Esc to cancel" }) })] }));
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Single choice (kind = 'choice')
53
+ // ---------------------------------------------------------------------------
54
+ function ChoiceForm({ params, onResolve }) {
55
+ const [cursor, setCursor] = useState(0);
56
+ useInput((_input, key) => {
57
+ if (key.escape) {
58
+ onResolve({ answer: null, cancelled: true });
59
+ return;
60
+ }
61
+ if (key.upArrow) {
62
+ setCursor((c) => Math.max(0, c - 1));
63
+ return;
64
+ }
65
+ if (key.downArrow) {
66
+ setCursor((c) => Math.min(params.choices.length - 1, c + 1));
67
+ return;
68
+ }
69
+ if (key.return) {
70
+ const chosen = params.choices[cursor];
71
+ if (chosen)
72
+ onResolve({ answer: chosen.value });
73
+ }
74
+ }, { isActive: true });
75
+ return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.choices.map((c, i) => {
76
+ const isCursor = i === cursor;
77
+ return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [isCursor ? '▸ ' : ' ', c.label] }, c.value));
78
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" }) })] }));
79
+ }
80
+ // ---------------------------------------------------------------------------
81
+ // Multi choice (kind = 'multi') — space to toggle, Enter to submit
82
+ // ---------------------------------------------------------------------------
83
+ function MultiForm({ params, onResolve }) {
84
+ const [cursor, setCursor] = useState(0);
85
+ const [selected, setSelected] = useState(new Set());
86
+ useInput((input, key) => {
87
+ if (key.escape) {
88
+ onResolve({ answer: null, cancelled: true });
89
+ return;
90
+ }
91
+ if (key.upArrow) {
92
+ setCursor((c) => Math.max(0, c - 1));
93
+ return;
94
+ }
95
+ if (key.downArrow) {
96
+ setCursor((c) => Math.min(params.choices.length - 1, c + 1));
97
+ return;
98
+ }
99
+ if (input === ' ') {
100
+ const current = params.choices[cursor];
101
+ if (current) {
102
+ setSelected((prev) => {
103
+ const next = new Set(prev);
104
+ if (next.has(current.value))
105
+ next.delete(current.value);
106
+ else
107
+ next.add(current.value);
108
+ return next;
109
+ });
110
+ }
111
+ return;
112
+ }
113
+ if (key.return) {
114
+ const values = params.choices
115
+ .filter((c) => selected.has(c.value))
116
+ .map((c) => c.value);
117
+ if (values.length === 0) {
118
+ onResolve({ answer: null, cancelled: true });
119
+ }
120
+ else {
121
+ onResolve({ answer: values.join(',') });
122
+ }
123
+ }
124
+ }, { isActive: true });
125
+ return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsx(Text, { bold: true, children: params.question }), params.context && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: params.context }) })), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.choices.map((c, i) => {
126
+ const isCursor = i === cursor;
127
+ const isSelected = selected.has(c.value);
128
+ const mark = isSelected ? '◉' : '○';
129
+ const prefix = isCursor ? '▸ ' : ' ';
130
+ return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [prefix, mark, " ", c.label] }, c.value));
131
+ }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Space toggle \u00B7 Enter submit \u00B7 Esc cancel" }) })] }));
132
+ }
@@ -4,16 +4,40 @@ import { Box, Text, useInput } from 'ink';
4
4
  import { PICK_CANCELLED } from '../skill-picker.js';
5
5
  const PEACH = '#FFCBA4';
6
6
  /**
7
- * Ink-mode coaching picker rendered as an overlay inside <App>. The parent
8
- * (App) mounts this component only when coachingPending !== null, so the
9
- * component's useInput being always-active is safe — it can only fire
10
- * while the overlay is visible.
7
+ * Ink-mode coaching picker — compact single-line-per-option layout.
11
8
  *
12
- * Cursor starts at 0 when params.preSelect (coach mode — Enter accepts top-1)
13
- * and at -1 when false (picker mode — user must arrow-key to their choice).
9
+ * Phase D3+ (2026-05-16): the prior layout used a bordered Box per
10
+ * option with 3 text rows each (skill+confidence, Does:, Pick if:).
11
+ * For 5 options that's ~25 lines — exceeded available vertical space
12
+ * once mounted inline between Body and Footer, and Ink overflow-
13
+ * clipped only the BOTTOM line of each option leaving a garbled
14
+ * picker (screenshot 71). This rewrite uses one line per option +
15
+ * a "details" line only for the highlighted entry, total height
16
+ * bounded to ~8 lines regardless of option count.
17
+ *
18
+ * Layout (example, 3 options):
19
+ * ╭ Pick a skill for: "What is the best part…?" ───────────╮
20
+ * │ ▸ /abap-explain 72% Plain-language code explainer │
21
+ * │ /abap-review 0% Code review vs quality criteria│
22
+ * │ /abap-document 0% Generate ABAP documentation │
23
+ * │ │
24
+ * │ ▸ /abap-explain — Pick if: pick this to teach code to a human │
25
+ * │ ↑/↓ navigate · Enter select · Esc cancel │
26
+ * ╰──────────────────────────────────────────────────────────╯
14
27
  */
15
28
  export function CoachingPicker({ params, onResolve }) {
16
- const [cursor, setCursor] = useState(params.preSelect ? 0 : -1);
29
+ // 2026-05-17 — initial cursor position priority:
30
+ // 1. preferSkill (the just-used skill, if present in options) — lets
31
+ // the user confirm "same skill as last turn" with one Enter press
32
+ // 2. preSelect=true → top-1 (classifier's best guess)
33
+ // 3. -1 → no selection, user must arrow-key to a choice
34
+ const preferIdx = params.preferSkill
35
+ ? params.options.findIndex((o) => o.skill === params.preferSkill)
36
+ : -1;
37
+ const initialCursor = preferIdx >= 0
38
+ ? preferIdx
39
+ : params.preSelect ? 0 : -1;
40
+ const [cursor, setCursor] = useState(initialCursor);
17
41
  useInput((_input, key) => {
18
42
  if (key.escape) {
19
43
  onResolve(PICK_CANCELLED);
@@ -37,5 +61,28 @@ export function CoachingPicker({ params, onResolve }) {
37
61
  onResolve(chosen.skill);
38
62
  }
39
63
  }, { isActive: true });
40
- return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, children: ["I'm not sure which skill fits for: \"", params.prompt.slice(0, 50), params.prompt.length > 50 ? '...' : '', "\"."] }), _jsx(Text, { bold: true, children: "Pick one:" }), params.options.map((opt, i) => (_jsxs(Box, { borderStyle: i === cursor ? 'round' : 'single', borderColor: i === cursor ? PEACH : 'gray', flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["/", opt.skill, " \u00B7 ", Math.round(opt.confidence * 100), "%"] }), _jsxs(Text, { dimColor: true, children: ["Does: ", opt.whatItDoes] }), _jsxs(Text, { dimColor: true, children: ["Pick if: ", opt.whenToPick] })] }, opt.skill))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" })] }));
64
+ const promptPreview = params.prompt.length > 60
65
+ ? params.prompt.slice(0, 57) + '…'
66
+ : params.prompt;
67
+ const highlighted = cursor >= 0 ? params.options[cursor] : undefined;
68
+ return (
69
+ // flexShrink={0} (2026-05-17) — without this, Ink's default
70
+ // flexShrink: 1 lets the picker get squashed when vertical space is
71
+ // tight, and the TOP options clip off (only the bottom one remained
72
+ // visible — see screenshot 74). flexShrink={0} guarantees the picker
73
+ // claims its natural height; Body shrinks to make room (and Body
74
+ // content stays recoverable via PgUp/Ctrl+B scroll).
75
+ _jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [_jsxs(Text, { bold: true, children: ["Pick a skill for: ", _jsxs(Text, { color: "cyan", children: ["\"", promptPreview, "\""] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: params.options.map((opt, i) => {
76
+ const isCursor = i === cursor;
77
+ const arrow = isCursor ? '▸ ' : ' ';
78
+ // 2026-05-17 — hide the "0%" cruft. Confidence is 0 for any
79
+ // alternate / fallback option (we never had a real score for
80
+ // them), AND for top-1 picks where the classifier itself
81
+ // returned 0. In both cases displaying "0%" looks broken.
82
+ // Show "%" only when there's a meaningful number.
83
+ const pctNum = Math.round(opt.confidence * 100);
84
+ const pct = pctNum > 0 ? `${pctNum.toString().padStart(3, ' ')}%` : ' ';
85
+ const name = `/${opt.skill}`.padEnd(20, ' ');
86
+ return (_jsxs(Text, { color: isCursor ? PEACH : undefined, bold: isCursor, children: [arrow, name, " ", pct, " ", opt.whatItDoes.slice(0, 60)] }, opt.skill));
87
+ }) }), highlighted && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Pick if: ", highlighted.whenToPick] }) })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" }) })] }));
41
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",