@cspeach/cli 1.0.0 → 1.1.1

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 (100) hide show
  1. package/dist/agent/loop.js +22 -9
  2. package/dist/approvals/op-labels.js +124 -0
  3. package/dist/approvals/render.js +42 -36
  4. package/dist/cli.js +15 -0
  5. package/dist/commands/compact.js +28 -2
  6. package/dist/commands/config-set.js +189 -0
  7. package/dist/commands/config-show.js +20 -0
  8. package/dist/commands/export-audit.js +43 -0
  9. package/dist/commands/help.js +5 -0
  10. package/dist/commands/plan-audit-evidence.js +266 -0
  11. package/dist/commands/plan-audit.js +692 -0
  12. package/dist/commands/plan-chain.js +671 -0
  13. package/dist/commands/plan-continue.js +179 -0
  14. package/dist/commands/plan-gate.js +154 -0
  15. package/dist/commands/plan-resume.js +588 -33
  16. package/dist/config/loader.js +128 -4
  17. package/dist/config/model-defaults.js +14 -0
  18. package/dist/cost/pricing.js +27 -1
  19. package/dist/doctor/checks/system-roles.js +41 -0
  20. package/dist/doctor/run.js +2 -0
  21. package/dist/models/resolve.js +61 -0
  22. package/dist/models/server-config.js +155 -0
  23. package/dist/one-shot.js +25 -3
  24. package/dist/projects/extract-cca.js +3 -1
  25. package/dist/projects/extract-modernize.js +3 -1
  26. package/dist/projects/extract-plan.js +60 -6
  27. package/dist/projects/extract-test-coverage.js +3 -1
  28. package/dist/projects/extract-upgrade.js +3 -1
  29. package/dist/projects/handover-md.js +195 -0
  30. package/dist/projects/index.js +1 -1
  31. package/dist/projects/plan-run.js +137 -13
  32. package/dist/projects/plan-schema.js +73 -0
  33. package/dist/projects/run-lease.js +157 -0
  34. package/dist/projects/save-command.js +26 -15
  35. package/dist/renderer/status-footer.js +22 -12
  36. package/dist/renderer/thinking-heartbeat.js +64 -8
  37. package/dist/renderer/todo-block.js +51 -0
  38. package/dist/renderer/tool-widget.js +37 -0
  39. package/dist/repl/bracketed-paste.js +28 -19
  40. package/dist/repl/builtin-commands.js +5 -0
  41. package/dist/repl/current-transport.js +10 -0
  42. package/dist/repl/history.js +86 -0
  43. package/dist/repl/ink-stdin-guard.js +64 -0
  44. package/dist/repl/mode-ceiling.js +16 -0
  45. package/dist/repl/mode-cycle.js +104 -0
  46. package/dist/repl/post-turn-status.js +24 -4
  47. package/dist/repl/slash-completer.js +5 -0
  48. package/dist/repl.js +954 -83
  49. package/dist/rewind/candidates.js +194 -0
  50. package/dist/rewind/cli.js +137 -0
  51. package/dist/rewind/format.js +27 -0
  52. package/dist/rewind/restore.js +245 -0
  53. package/dist/session/audit-export.js +459 -0
  54. package/dist/session/context-report.js +163 -0
  55. package/dist/session/recap.js +160 -0
  56. package/dist/skill-catalog.js +9 -3
  57. package/dist/skills/bundled-skills.js +71 -78
  58. package/dist/tools/approval.js +115 -7
  59. package/dist/tools/ask-question.js +304 -3
  60. package/dist/tools/extend-model/anchored-insert.js +604 -0
  61. package/dist/tools/extend-model/tool.js +162 -10
  62. package/dist/tools/fiori/fe-extend.js +76 -0
  63. package/dist/tools/fiori/fe-scaffold.js +29 -3
  64. package/dist/tools/fiori/floorplan-map.js +19 -0
  65. package/dist/tools/fiori/samples/data/index.json +13602 -0
  66. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  67. package/dist/tools/fiori/samples/loader.js +248 -0
  68. package/dist/tools/fiori/samples/search.js +63 -0
  69. package/dist/tools/fiori/samples/types.js +2 -0
  70. package/dist/tools/fiori/smoke/assertions.js +74 -0
  71. package/dist/tools/fiori/smoke/browser.js +52 -0
  72. package/dist/tools/fiori/smoke/driver.js +89 -0
  73. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  74. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  75. package/dist/tools/fiori/tools.js +328 -3
  76. package/dist/tools/local-build.js +11 -1
  77. package/dist/tools/sap-read.js +79 -11
  78. package/dist/tools/sap-write.js +24 -4
  79. package/dist/tools/snapshot.js +27 -1
  80. package/dist/tools/subagent/agent_run.js +27 -3
  81. package/dist/tools/todo.js +144 -0
  82. package/dist/ui/app.js +372 -19
  83. package/dist/ui/approval-modal.js +49 -16
  84. package/dist/ui/ask-question-emitter.js +14 -0
  85. package/dist/ui/context-grid.js +108 -0
  86. package/dist/ui/footer.js +109 -30
  87. package/dist/ui/header.js +7 -0
  88. package/dist/ui/line-resolution.js +18 -2
  89. package/dist/ui/rewind-emitter.js +10 -0
  90. package/dist/ui/rewind-panel.js +81 -0
  91. package/dist/ui/sap-state-store.js +1 -0
  92. package/dist/ui/status-line.js +43 -0
  93. package/dist/ui/text-input.js +72 -8
  94. package/dist/ui/todo-emitter.js +25 -0
  95. package/dist/ui/todo-panel.js +64 -0
  96. package/dist/ui/turn-status-emitter.js +50 -4
  97. package/dist/ui/turn-status.js +18 -3
  98. package/dist/ui/widgets/ask-form.js +242 -0
  99. package/dist/ui/widgets/ask-question-modal.js +17 -7
  100. package/package.json +4 -1
@@ -0,0 +1,108 @@
1
+ // cspeach-cli/src/ui/context-grid.tsx
2
+ //
3
+ // UX Wave 3 / Task 5 — render the /context composition grid to lines of text.
4
+ //
5
+ // One bar per segment, proportional to the exact billed prompt total (share of
6
+ // USED context), capped at 40 columns. Classic mode = the same lines minus
7
+ // color, so this is a single
8
+ // pure line-builder with a `color` flag rather than two renderers. The Ink
9
+ // dispatch path emits these lines through chunkEmitter; the classic path
10
+ // console.logs them — same seam as /cost and /export.
11
+ //
12
+ // Wave-1 invariant (binding): NO row may ever wrap. Every row is truncated end
13
+ // to the terminal width AS PLAIN TEXT FIRST, then color is applied to the
14
+ // already-truncated string — so colored lines are truncated identically to
15
+ // classic ones and an ANSI escape can never be split by the cut.
16
+ import chalk from 'chalk';
17
+ import { shouldUseUnicodeBoxChars } from '../renderer/tty.js';
18
+ import { buildContextReport } from '../session/context-report.js';
19
+ import { readCostLog } from '../cost/cost-log.js';
20
+ /** Bars never exceed this many columns (Wave-1 proportional-bar cap). */
21
+ const BAR_MAX = 40;
22
+ /** Segment labels are padded to this width so values line up. */
23
+ const LABEL_WIDTH = 22;
24
+ /**
25
+ * Values (`~130k`) are padded to this width so the bars that follow line up.
26
+ * `~` + up to 3 digits + `k` = 5; a couple of spare columns keep three-digit
27
+ * k-values aligned. The value sits BEFORE the bar (triage #14) so a narrow
28
+ * terminal truncates bar characters, never the number's digits.
29
+ */
30
+ const VALUE_WIDTH = 7;
31
+ /** k-format a token count: 130400 → "130k". */
32
+ function k(n) {
33
+ return `${Math.round(n / 1000)}k`;
34
+ }
35
+ /** Truncate a plain string to `width` columns (end-truncation, no wrap). */
36
+ function truncateEnd(s, width) {
37
+ if (width <= 0)
38
+ return '';
39
+ return s.length > width ? s.slice(0, width) : s;
40
+ }
41
+ /**
42
+ * Build the /context grid as an array of ready-to-emit lines.
43
+ *
44
+ * The header shows the one exact number (lastTurnInput, in k). Every segment
45
+ * row shows a `~` (approx) token figure — the heuristic honesty marker. Bars
46
+ * are scaled against lastTurnInput so they read as "share of the USED
47
+ * context", and capped at BAR_MAX.
48
+ */
49
+ export function renderContextGrid(report, opts = {}) {
50
+ const color = opts.color ?? true;
51
+ const width = opts.width ?? (typeof process !== 'undefined' && process.stdout?.columns ? process.stdout.columns : 80);
52
+ const unicode = shouldUseUnicodeBoxChars();
53
+ const fill = unicode ? '█' : '#';
54
+ const arrow = unicode ? '→' : '->';
55
+ const lines = [];
56
+ // ── header: context: 87k / 200k (43%) ── (the only exact number)
57
+ // Truncate the PLAIN text first, then color — so the width cut is identical
58
+ // in both modes and can never land inside an ANSI escape.
59
+ const pctInt = Math.round(report.pct * 100);
60
+ const headerText = truncateEnd(`context: ${k(report.lastTurnInput)} / ${k(report.windowTokens)} (${pctInt}%)`, width);
61
+ lines.push(color ? chalk.bold(headerText) : headerText);
62
+ lines.push('');
63
+ // Scale bars against the exact billed prompt total (lastTurnInput) so a
64
+ // segment's bar reads as its share of the USED context, capped at BAR_MAX.
65
+ // Fall back to the largest segment when there is no billed input yet (so an
66
+ // unbilled session still renders a readable grid).
67
+ const scaleBase = report.lastTurnInput > 0
68
+ ? report.lastTurnInput
69
+ : Math.max(1, ...report.segments.map((s) => s.approxTokens));
70
+ for (const seg of report.segments) {
71
+ const barLen = Math.min(BAR_MAX, Math.max(0, Math.round((BAR_MAX * seg.approxTokens) / scaleBase)));
72
+ const bar = fill.repeat(barLen);
73
+ const label = seg.label.padEnd(LABEL_WIDTH).slice(0, LABEL_WIDTH);
74
+ const value = `~${k(seg.approxTokens)}`;
75
+ // Value BEFORE the bar (triage #14): a narrow terminal truncates the bar
76
+ // run, never the number's digits (value-after-bar clipped "~130k" → "~1").
77
+ // Plain, color-free row: identical bytes in both modes → truncation can
78
+ // never split an ANSI escape, and classic == Ink minus color trivially.
79
+ const row = `${label} ${value.padEnd(VALUE_WIDTH)} ${bar}`;
80
+ lines.push(truncateEnd(row, width));
81
+ }
82
+ if (report.hints.length > 0) {
83
+ lines.push('');
84
+ for (const h of report.hints) {
85
+ // Same order as the header: plain-truncate, THEN color.
86
+ const text = truncateEnd(`${arrow} ${h}`, width);
87
+ lines.push(color ? chalk.dim(text) : text);
88
+ }
89
+ }
90
+ return lines;
91
+ }
92
+ /**
93
+ * `/context` command — read the session's cost log, build the composition
94
+ * report, render the grid, and emit it line by line through the caller's sink
95
+ * (chunkEmitter in Ink mode, console.log in classic mode — same seam as
96
+ * runCostCommand). Read-only, no network. `costEntries` is injectable for
97
+ * tests; otherwise it is read from the session's JSONL log.
98
+ */
99
+ export async function runContextCommand(opts) {
100
+ const { session, emit } = opts;
101
+ const costEntries = opts.costEntries ?? (await readCostLog(session.id));
102
+ const report = buildContextReport(session, costEntries);
103
+ const lines = renderContextGrid(report, { color: opts.color });
104
+ emit('');
105
+ for (const line of lines)
106
+ emit(line);
107
+ emit('');
108
+ }
package/dist/ui/footer.js CHANGED
@@ -1,52 +1,94 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useState, useEffect, useCallback } from 'react';
3
- import { Box, Text, useInput } from 'ink';
2
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
4
  // D29 paste fix (2026-06-12): vendored paste-safe replacement for
5
5
  // ink-text-input — multi-chunk pastes were losing every chunk but the
6
6
  // last (stale-prop closure in the upstream component). See text-input.tsx.
7
7
  import TextInput from './text-input.js';
8
8
  import Fuse from 'fuse.js';
9
- import * as fs from 'node:fs';
10
- import * as os from 'node:os';
11
- import * as path from 'node:path';
12
- // Resolved lazily (not at module load) so it honours the HOME/USERPROFILE
13
- // active at render time — lets tests point it at a temp dir.
14
- function historyFile() {
15
- return path.join(os.homedir(), '.cspeach', 'history');
16
- }
9
+ // Task 8 (ux-wave1) — persistence lives in the shared writer (one file,
10
+ // one dedup guard for the Footer, Ink handleSubmit, and classic readline).
11
+ // It resolves os.homedir() lazily so tests can point it at a temp dir.
12
+ import { appendHistoryEntry, readHistoryEntries, flattenHistoryEntry } from '../repl/history.js';
17
13
  function loadHistory() {
18
- try {
19
- return fs.readFileSync(historyFile(), 'utf8').split('\n').filter(Boolean);
20
- }
21
- catch {
22
- return [];
23
- }
14
+ return readHistoryEntries();
24
15
  }
25
16
  function appendHistory(line, history) {
26
- // Skip consecutive duplicates (matches bash HISTCONTROL=ignoredups).
27
- if (history[history.length - 1] === line)
28
- return;
29
- const file = historyFile();
30
- fs.mkdirSync(path.dirname(file), { recursive: true });
31
- fs.appendFileSync(file, line + '\n');
32
- history.push(line);
17
+ // The shared writer flattens (newlines/tabs/C0 controls → ' '), trims,
18
+ // and skips consecutive duplicates (bash HISTCONTROL=ignoredups / CC
19
+ // parity). Mirror into the in-memory array ONLY when the entry was
20
+ // actually written, so arrow-up and Ctrl+R stay consistent with the
21
+ // file. Same flatten as the writer (tab-flatten fast-follow) so the
22
+ // mirror never diverges from the persisted entry.
23
+ const entry = flattenHistoryEntry(line);
24
+ if (appendHistoryEntry(entry))
25
+ history.push(entry);
33
26
  }
34
27
  const PEACH = '#FFCBA4';
35
- export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled }) {
28
+ /** The input prompt — its width feeds the display budget below. */
29
+ const PROMPT = 'CSPeach › ';
30
+ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, prefill, disabled, onNotice, onBufferChange }) {
36
31
  const [value, setValue] = useState('');
32
+ // Wave1 live-fix (Bug 1, statusline duplication) — the input row must
33
+ // NEVER soft-wrap in the terminal: Ink's log-update erases logical
34
+ // lines, not terminal rows, so one wrapped dynamic-frame row leaks the
35
+ // StatusLine into scrollback on every keystroke repaint (see the
36
+ // maxDisplayWidth doc in text-input.tsx for the ink source refs). Cap
37
+ // the displayed value at columns − prompt − 1 (the −1 keeps the row off
38
+ // the exact-width deferred-wrap edge on Windows Terminal). Re-rendered
39
+ // on stdout resize so a narrowed window tightens the budget promptly.
40
+ const { stdout } = useStdout();
41
+ const [, bumpOnResize] = useState(0);
42
+ useEffect(() => {
43
+ if (!stdout)
44
+ return;
45
+ const onResize = () => bumpOnResize((n) => n + 1);
46
+ stdout.on('resize', onResize);
47
+ return () => { stdout.off('resize', onResize); };
48
+ }, [stdout]);
49
+ const columns = stdout?.columns || 80;
50
+ const inputBudget = Math.max(8, columns - PROMPT.length - 1);
37
51
  const [history] = useState(loadHistory);
38
52
  const [histIdx, setHistIdx] = useState(-1);
39
53
  const [fuzzyMode, setFuzzyMode] = useState(false);
40
54
  const [fuzzyQuery, setFuzzyQuery] = useState('');
41
55
  const [fuzzyResults, setFuzzyResults] = useState([]);
56
+ // Task 7 (ux-wave1) — multiline continuation buffer. Holds COMPLETE
57
+ // lines (from trailing-`\` submits and multi-line pastes) until the
58
+ // final Enter joins them with the current input line into ONE message.
59
+ // The TextInput never sees this state — it stays single-line.
60
+ //
61
+ // bufferRef is the synchronous mirror of the same D29 shape as
62
+ // text-input's valueRef: a paste tail and its Enter can land in ONE
63
+ // stdin drain, so onPasteLines and handleSubmit may both run before
64
+ // React commits the state update. All reads/writes go through the ref;
65
+ // the state exists only to drive the indicator render.
66
+ const [buffer, setBuffer] = useState([]);
67
+ const bufferRef = useRef([]);
68
+ const updateBuffer = useCallback((lines) => {
69
+ bufferRef.current = lines;
70
+ setBuffer(lines);
71
+ }, []);
42
72
  const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
43
73
  // Mirror state into parent when it cares (e.g. App's ? key-binding gate).
44
74
  useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
75
+ // Task 7 — mirror the buffer depth into App (live-trigger suppression).
76
+ useEffect(() => { onBufferChange?.(buffer.length); }, [buffer.length, onBufferChange]);
77
+ // Task 7 — complete lines carved out of a multi-line paste by the
78
+ // TextInput (see onPasteLines in text-input.tsx). Append, never submit.
79
+ const handlePasteLines = useCallback((lines) => {
80
+ updateBuffer([...bufferRef.current, ...lines]);
81
+ }, [updateBuffer]);
45
82
  // If the parent pushes a prefill (palette pick, palette cancel restore,
46
83
  // empty-body guard), pre-fill our input. Keyed on the nonce so identical
47
- // text can re-fire.
84
+ // text can re-fire. Empty text is honoured too (Task 5): App's live
85
+ // key-triggers (`/`, `?`, Alt+M) push an empty prefill to SWALLOW the
86
+ // trigger character, which Ink also delivers to our TextInput — without
87
+ // the clear, the leaked char reappears once the overlay closes. Nonce 0
88
+ // is the untouched initial state (App never pushes nonce 0), so skip it
89
+ // rather than clearing on mount.
48
90
  useEffect(() => {
49
- if (prefill && prefill.text.length > 0)
91
+ if (prefill && prefill.nonce > 0)
50
92
  setValue(prefill.text);
51
93
  // eslint-disable-next-line react-hooks/exhaustive-deps
52
94
  }, [prefill?.nonce]);
@@ -78,6 +120,15 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
78
120
  }
79
121
  return;
80
122
  }
123
+ // Task 7 — Esc discards the multiline draft. Runs AFTER the fuzzy
124
+ // branch (fuzzy Esc closes the search) and only while this hook is
125
+ // active at all (isActive: !disabled) — when a palette/modal overlay
126
+ // is up the Footer is disabled, so overlay-Esc never races this.
127
+ if (key.escape && bufferRef.current.length > 0) {
128
+ updateBuffer([]);
129
+ onNotice?.('multiline draft discarded');
130
+ return;
131
+ }
81
132
  if (key.upArrow) {
82
133
  const next = Math.min(histIdx + 1, history.length - 1);
83
134
  setHistIdx(next);
@@ -98,6 +149,34 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
98
149
  // why the TextInput is swapped for a placeholder when disabled.
99
150
  }, { isActive: !disabled });
100
151
  const handleSubmit = useCallback((line) => {
152
+ // Task 7 — trailing `\` continues the message: strip the backslash,
153
+ // hold the line (indentation preserved — this is for ABAP snippets),
154
+ // clear the input, submit nothing. A bare `\` inserts a blank line.
155
+ if (line.endsWith('\\')) {
156
+ updateBuffer([...bufferRef.current, line.slice(0, -1)]);
157
+ setHistIdx(-1);
158
+ setValue('');
159
+ return;
160
+ }
161
+ // Task 7 — final submit with buffered lines: join into ONE message.
162
+ // The onSubmit contract is unchanged (one string, may contain \n) —
163
+ // App's beginSubmit / steering path takes it as a single message.
164
+ // The bare-`/` and `/history` intercepts are intentionally skipped
165
+ // here: mid-composition a `/` line is literal text (same pin as the
166
+ // live-trigger suppression in App).
167
+ if (bufferRef.current.length > 0) {
168
+ const joined = [...bufferRef.current, line].join('\n').trim();
169
+ updateBuffer([]);
170
+ setHistIdx(-1);
171
+ setValue('');
172
+ if (!joined)
173
+ return;
174
+ // The history file is one-entry-per-line; flatten so a multiline
175
+ // message doesn't split into N bogus entries on the next load.
176
+ appendHistory(joined.replace(/\n/g, ' '), history);
177
+ onSubmit(joined);
178
+ return;
179
+ }
101
180
  if (!line.trim())
102
181
  return;
103
182
  appendHistory(line.trim(), history);
@@ -112,9 +191,9 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
112
191
  return;
113
192
  }
114
193
  onSubmit(line.trim());
115
- }, [onSubmit, onSlashTrigger, onHistoryTrigger, history]);
194
+ }, [onSubmit, onSlashTrigger, onHistoryTrigger, history, updateBuffer]);
116
195
  if (fuzzyMode) {
117
- 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" })] }));
196
+ 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...", maxDisplayWidth: Math.max(8, columns - 5) }), fuzzyResults.map((r, i) => (_jsx(Text, { dimColor: i > 0, wrap: "truncate-end", children: flattenHistoryEntry(r) }, i))), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
118
197
  }
119
198
  // 2026-05-17 — when a modal owns input (coaching picker / ask_question),
120
199
  // render a dim placeholder INSTEAD of the live TextInput. Otherwise
@@ -122,7 +201,7 @@ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChan
122
201
  // Footer submission fires after the modal resolves → spurious picker
123
202
  // for what the user thought was their modal answer.
124
203
  if (disabled) {
125
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
204
+ return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(Text, { dimColor: true, children: "(answer above to continue)" })] }));
126
205
  }
127
- return (_jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })] }));
206
+ return (_jsxs(Box, { flexDirection: "column", children: [buffer.length > 0 && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2026 ", buffer.length, " line", buffer.length === 1 ? '' : 's', " pending (Enter sends, \\ continues)"] })), _jsxs(Box, { children: [_jsx(Text, { bold: true, color: PEACH, children: PROMPT }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit, onPasteLines: handlePasteLines, maxDisplayWidth: inputBudget })] })] }));
128
207
  }
package/dist/ui/header.js CHANGED
@@ -4,6 +4,13 @@ import { useSapState } from './sap-state-store.js';
4
4
  import { CLI_VERSION } from '../lib/version.js';
5
5
  const PEACH = '#FFCBA4';
6
6
  /**
7
+ * ⚠ SUPERSEDED (UX Wave 1, Task 4 — 2026-07) by ui/status-line.tsx, the
8
+ * bottom-anchored borderless statusline mounted in App between TurnStatus
9
+ * and Footer. This component is no longer rendered anywhere: Phase D6
10
+ * removed the persistent top Header (inline mode can't pin the frame top),
11
+ * and the statusline took over the SapStateStore display role. Kept for
12
+ * reference, not deleted.
13
+ *
7
14
  * Single-line rich header (Phase D3, 2026-05-16) — back to one line per
8
15
  * Laeeq's feedback that the two-line variant felt worse. Uses tighter
9
16
  * separators and shorter labels so it fits cleanly on typical terminals
@@ -49,9 +49,25 @@ export function nextResolutionStep(line, files) {
49
49
  // produce its normal "no file matching" message.
50
50
  return emptyBodyGuard(trimmed);
51
51
  }
52
- /** Substitute the picked command head into the pending line and continue. */
53
- export function applySlashPick(pickedName, rest, files) {
52
+ /**
53
+ * Substitute the picked command head into the pending line and continue.
54
+ *
55
+ * `fillIn` (UX Wave 1, Task 5 — CC v2.1.19x behavior): true when the palette
56
+ * was opened by the LIVE bare-`/` trigger (original line empty). A pick then
57
+ * PREFILLS `/{command} ` into the Footer — never submits, not even for
58
+ * builtins that take no body — so the user can add arguments before Enter.
59
+ * The typed-and-submitted flow (fillIn=false, the default) keeps today's
60
+ * semantics: re-run the pipeline, which submits when the line is complete.
61
+ */
62
+ export function applySlashPick(pickedName, rest, files, fillIn = false) {
54
63
  const name = pickedName.startsWith('/') ? pickedName : `/${pickedName}`;
64
+ if (fillIn) {
65
+ return {
66
+ kind: 'prefill',
67
+ text: `${name} `,
68
+ hint: `↪ ${name} — add arguments or press Enter to run`,
69
+ };
70
+ }
55
71
  return nextResolutionStep(`${name}${rest}`, files);
56
72
  }
57
73
  /** Substitute the picked filename into the rightmost @-token and continue. */
@@ -0,0 +1,10 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /**
3
+ * Open-request channel for the Rewind panel (UX Wave 3 / Task 3). The Esc-Esc
4
+ * chord opens the panel from inside <App>'s own useInput; the `/rewind` builtin
5
+ * lives in repl.tsx (a separate React-less dispatch), so it signals the mounted
6
+ * <App> through this singleton emitter — the same producer→consumer pattern as
7
+ * todoEmitter / approvalEmitter. In classic mode <App> is never mounted, this
8
+ * emitter has no listeners, and `/rewind` takes the inquirer path instead.
9
+ */
10
+ export const rewindEmitter = new EventEmitter();
@@ -0,0 +1,81 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { hhmm, rewindRowLabel, packageUnknown } from '../rewind/format.js';
5
+ /**
6
+ * RewindPanel — UX Wave 3 / Task 3. The Esc-Esc (and /rewind) overlay that
7
+ * turns "undo for your SAP system" into a picker. House style follows
8
+ * SessionTimeline / TodoPanel / CommandPalette: a bordered box mounted above
9
+ * the Footer by <App>, closes on Esc / q, every row wrap="truncate-end"
10
+ * (Wave-1 rule — no dynamic-frame row may soft-wrap), and lists longer than
11
+ * REWIND_VISIBLE_ROWS scroll in a window (CommandPalette pattern).
12
+ *
13
+ * Row: `▸ ZMNT_NOTE (TABL) · set_source · 14:32 · snapshot 12:01`
14
+ * - restorable → bold, cyan when the cursor is on it.
15
+ * - restorable delete with an unknown recreate package → a dim
16
+ * `(package unknown — restore may fail)` tail (rider c).
17
+ * - non-restorable → dim, with the candidate's reason.
18
+ *
19
+ * Enter on a restorable row swaps the footer hint for an inline confirm line
20
+ * (`restore … to snapshot 12:01? runs the write gates — [y]/[n]`). `y` fires
21
+ * onRestore(candidate) and closes; `n` / Esc returns to the list. The panel
22
+ * NEVER runs the write itself — onRestore bridges to the gated executor.
23
+ *
24
+ * Empty state: a single dim `no restorable writes this session` line that
25
+ * closes on any key.
26
+ */
27
+ export const REWIND_VISIBLE_ROWS = 10;
28
+ export function RewindPanel({ candidates, onRestore, onClose }) {
29
+ const [cursor, setCursor] = useState(0);
30
+ const [confirming, setConfirming] = useState(null);
31
+ const empty = candidates.length === 0;
32
+ useInput((input, key) => {
33
+ // Empty state: any key dismisses.
34
+ if (empty) {
35
+ onClose();
36
+ return;
37
+ }
38
+ if (confirming) {
39
+ if (input === 'y' || input === 'Y') {
40
+ onRestore(confirming);
41
+ onClose();
42
+ return;
43
+ }
44
+ // n / N / Esc / q — back to the list, nothing written.
45
+ setConfirming(null);
46
+ return;
47
+ }
48
+ if (key.escape || input === 'q') {
49
+ onClose();
50
+ return;
51
+ }
52
+ if (key.upArrow) {
53
+ setCursor((cu) => Math.max(0, cu - 1));
54
+ return;
55
+ }
56
+ if (key.downArrow) {
57
+ setCursor((cu) => Math.min(candidates.length - 1, cu + 1));
58
+ return;
59
+ }
60
+ if (key.return) {
61
+ const c = candidates[cursor];
62
+ if (c && c.restorable)
63
+ setConfirming(c); // no-op on a dimmed non-restorable row
64
+ return;
65
+ }
66
+ });
67
+ const maxOffset = Math.max(0, candidates.length - REWIND_VISIBLE_ROWS);
68
+ const windowStart = Math.max(0, Math.min(cursor - 3, maxOffset));
69
+ const visible = candidates.slice(windowStart, windowStart + REWIND_VISIBLE_ROWS);
70
+ const below = Math.max(0, candidates.length - (windowStart + REWIND_VISIBLE_ROWS));
71
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 72, children: [_jsx(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: "Rewind \u2014 undo a write this session \u00B7 times UTC" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [empty && _jsx(Text, { dimColor: true, children: "no restorable writes this session" }), !empty && windowStart > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", windowStart, " more"] }), !empty && visible.map((c, i) => {
72
+ const idx = windowStart + i;
73
+ const sel = idx === cursor;
74
+ const glyph = sel ? '▸ ' : ' ';
75
+ return (_jsxs(React.Fragment, { children: [c.restorable
76
+ ? _jsxs(Text, { bold: true, color: sel ? 'cyan' : undefined, wrap: "truncate-end", children: [glyph, rewindRowLabel(c)] })
77
+ : _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [glyph, rewindRowLabel(c)] }), packageUnknown(c) && (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: " package unknown \u2014 restore may fail" }))] }, idx));
78
+ }), !empty && below > 0 && _jsxs(Text, { dimColor: true, children: [" \u2193 ", below, " more"] })] }), _jsx(Box, { marginTop: 1, children: confirming ? (_jsxs(Text, { color: "yellow", wrap: "truncate-end", children: ["restore ", confirming.object, " to snapshot ", hhmm(confirming.snapshotAt), "? runs write gates \u2014 [y]/[n]"] })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: empty
79
+ ? 'any key to close'
80
+ : `${candidates.length > REWIND_VISIBLE_ROWS ? '↑↓ scroll · ' : ''}Enter restore · Esc/q close` })) })] }));
81
+ }
@@ -14,6 +14,7 @@ import React, { createContext, useContext } from 'react';
14
14
  const DEFAULT_STATE = {
15
15
  system: '', user: '', transport: null, locksHeld: [],
16
16
  tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 100, recentTurns: [],
17
+ writeMode: null,
17
18
  };
18
19
  /**
19
20
  * Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from 'ink';
3
+ import { useSapState } from './sap-state-store.js';
4
+ /**
5
+ * Persistent SAP statusline (UX Wave 1, Task 4) — Header reborn,
6
+ * bottom-anchored. Single borderless line rendered between <TurnStatus />
7
+ * and <Footer /> in the dynamic Ink frame:
8
+ *
9
+ * S4H │ S.LAEEQ │ tr K903417 │ approval-gated │ $0.54
10
+ *
11
+ * Descends from ui/header.tsx (Phase D3) minus the border (3 rows → 1) and
12
+ * the version/🍑 (the startup banner owns those). All fields come from the
13
+ * SapStateStore singleton, so transport creation, spend accrual, and
14
+ * write-mode cycling (Shift+Tab / Alt+M / /mode) re-render it live.
15
+ *
16
+ * The mode chip is omitted until the store's writeMode is seeded at REPL
17
+ * startup (null = pre-seed; only reachable in tests and the first ms of
18
+ * mount). Chip colors: advisory-only yellow / approval-gated green / auto
19
+ * cyan.
20
+ */
21
+ const MODE_COLORS = {
22
+ 'advisory-only': 'yellow',
23
+ 'approval-gated': 'green',
24
+ auto: 'cyan',
25
+ };
26
+ export function StatusLine() {
27
+ const s = useSapState();
28
+ const spendDisplay = `$${s.dollarSpendToday.toFixed(2)}`;
29
+ // Wave1 live-fix (Bug 1) — one TRUNCATING <Text> instead of a <Box> of
30
+ // sibling Texts. The statusline lives in the dynamic Ink frame, where a
31
+ // row that soft-wraps in the terminal breaks log-update's logical-line
32
+ // erase accounting and leaks stale rows into scrollback (the live
33
+ // "statusline printed 11×" bug — see text-input.tsx maxDisplayWidth doc
34
+ // for the ink source refs). Nested <Text> children squash into one text
35
+ // node, so wrap="truncate-end" clips the whole row at the terminal
36
+ // width via ANSI-aware cli-truncate — this row can never wrap, whatever
37
+ // the field lengths.
38
+ return (_jsxs(Text, { wrap: "truncate-end", children: [s.system
39
+ ? _jsx(Text, { color: "cyan", children: s.system })
40
+ : _jsx(Text, { dimColor: true, children: "no system" }), _jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { dimColor: true, children: " \u2502 tr " }), s.transport
41
+ ? _jsx(Text, { color: "yellow", children: s.transport })
42
+ : _jsx(Text, { dimColor: true, children: "none" }), s.writeMode && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: MODE_COLORS[s.writeMode], children: s.writeMode })] })), _jsx(Text, { dimColor: true, children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay })] }));
43
+ }
@@ -27,7 +27,28 @@ import { jsx as _jsx } from "react/jsx-runtime";
27
27
  import { useState, useEffect, useRef } from 'react';
28
28
  import { Text, useInput } from 'ink';
29
29
  import chalk from 'chalk';
30
- export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, }) {
30
+ /**
31
+ * Pure windowing core (exported for tests). Given the value length, the
32
+ * cursor position, the display budget (character cells) and whether the
33
+ * fake cursor is rendered, return the [start, end) slice of the value to
34
+ * display. Invariants:
35
+ * - (end - start) + (cursor-at-end cell ? 1 : 0) <= budget
36
+ * - start <= cursorOffset <= start + budget - 1 (cursor always visible)
37
+ * - tail-biased: typing at the end shows the end of the value.
38
+ */
39
+ export function computeDisplayWindow(valueLength, cursorOffset, budget, cursorVisible) {
40
+ if (!budget || budget <= 0)
41
+ return { start: 0, end: valueLength };
42
+ const virtualLength = valueLength + (cursorVisible ? 1 : 0); // trailing cursor cell
43
+ if (virtualLength <= budget)
44
+ return { start: 0, end: valueLength };
45
+ let start = virtualLength - budget; // default: show the tail
46
+ if (cursorOffset < start)
47
+ start = cursorOffset; // scrolled left past the window
48
+ const end = Math.min(valueLength, start + budget);
49
+ return { start, end };
50
+ }
51
+ export function PasteSafeTextInput({ value: originalValue, placeholder = '', focus = true, mask, showCursor = true, onChange, onSubmit, onPasteLines, maxDisplayWidth, }) {
31
52
  const [cursorOffset, setCursorOffset] = useState((originalValue || '').length);
32
53
  // The paste-safety core: synchronous mirrors of value + cursor. The
33
54
  // input handler reads/writes THESE, never the (possibly stale) props.
@@ -66,21 +87,37 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
66
87
  setCursorOffset((previous) => previous > originalValue.length ? originalValue.length : previous);
67
88
  }, [originalValue, focus, showCursor]);
68
89
  const value = mask ? mask.repeat(originalValue.length) : originalValue;
69
- let renderedValue = value;
90
+ // Wave1 live-fix (Bug 1) — display-only horizontal window + control-char
91
+ // flattening. The mirrors, the props, and every handler above operate on
92
+ // the FULL value; only what's painted is windowed, so the row can never
93
+ // soft-wrap in the terminal (see maxDisplayWidth doc). Tabs are flattened
94
+ // to one space IN THE DISPLAY ONLY: string-width measures '\t' as 0 while
95
+ // terminals render up to 8 cells — the exact measurement drift that broke
96
+ // Ink's erase accounting live.
97
+ const cursorRendered = showCursor && focus;
98
+ const win = computeDisplayWindow(value.length, cursorOffset, maxDisplayWidth, cursorRendered);
99
+ const displayCursor = cursorOffset - win.start; // may equal display length (end cell)
100
+ // Left-clip marker: swap the first visible char for '…' (same cell
101
+ // count, so the budget still holds) unless the cursor sits on that cell.
102
+ const clipMarker = win.start > 0 && displayCursor !== 0;
103
+ const displayValue = (clipMarker ? '…' : '') +
104
+ value.slice(win.start + (clipMarker ? 1 : 0), win.end).replace(/\t/g, ' ');
105
+ let renderedValue = displayValue;
70
106
  let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
71
- // Fake cursor — vendored rendering from ink-text-input v6.
72
- if (showCursor && focus) {
107
+ // Fake cursor — vendored rendering from ink-text-input v6, over the
108
+ // windowed display string.
109
+ if (cursorRendered) {
73
110
  renderedPlaceholder =
74
111
  placeholder.length > 0
75
112
  ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
76
113
  : chalk.inverse(' ');
77
- renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
114
+ renderedValue = displayValue.length > 0 ? '' : chalk.inverse(' ');
78
115
  let i = 0;
79
- for (const char of value) {
80
- renderedValue += i === cursorOffset ? chalk.inverse(char) : char;
116
+ for (const char of displayValue) {
117
+ renderedValue += i === displayCursor ? chalk.inverse(char) : char;
81
118
  i++;
82
119
  }
83
- if (value.length > 0 && cursorOffset === value.length) {
120
+ if (displayValue.length > 0 && displayCursor === displayValue.length) {
84
121
  renderedValue += chalk.inverse(' ');
85
122
  }
86
123
  }
@@ -102,6 +139,9 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
102
139
  const currentValue = valueRef.current;
103
140
  let nextCursorOffset = cursorRef.current;
104
141
  let nextValue = currentValue;
142
+ // Task 7 — complete lines carved out of a newline-bearing paste
143
+ // chunk; reported to the parent AFTER the mirrors are updated.
144
+ let pasteLines = null;
105
145
  if (key.leftArrow) {
106
146
  if (showCursor)
107
147
  nextCursorOffset--;
@@ -118,6 +158,28 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
118
158
  nextCursorOffset--;
119
159
  }
120
160
  }
161
+ else if (onPasteLines && (input.includes('\n') || input.includes('\r'))) {
162
+ // Task 7 — multi-line paste chunk. Normalise CRLF / bare CR
163
+ // (Windows Terminal converts pasted newlines to \r in raw mode),
164
+ // insert at the cursor as usual, then split at the LAST newline:
165
+ // everything before it is complete lines for the parent's
166
+ // continuation buffer; the tail fragment becomes the new value.
167
+ // Because the fragment (not the full paste) lands in the
168
+ // synchronous mirror, a follow-up chunk of the same drain
169
+ // compounds on the fragment — duplicate delivery of a completed
170
+ // line is structurally impossible, and every D29 mirror/echo
171
+ // mechanism below operates exactly as for a plain insert.
172
+ const cleanInput = input.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
173
+ const combined = currentValue.slice(0, cursorRef.current) +
174
+ cleanInput +
175
+ currentValue.slice(cursorRef.current, currentValue.length);
176
+ const lastNl = combined.lastIndexOf('\n');
177
+ pasteLines = combined.slice(0, lastNl).split('\n');
178
+ nextValue = combined.slice(lastNl + 1);
179
+ // Cursor lands where the insertion ended, mapped into the fragment
180
+ // (0 when the paste ended on the newline itself).
181
+ nextCursorOffset = Math.max(0, cursorRef.current + cleanInput.length - (lastNl + 1));
182
+ }
121
183
  else {
122
184
  nextValue =
123
185
  currentValue.slice(0, cursorRef.current) +
@@ -134,6 +196,8 @@ export function PasteSafeTextInput({ value: originalValue, placeholder = '', foc
134
196
  valueRef.current = nextValue;
135
197
  cursorRef.current = nextCursorOffset;
136
198
  setCursorOffset(nextCursorOffset);
199
+ if (pasteLines)
200
+ onPasteLines?.(pasteLines);
137
201
  if (nextValue !== currentValue) {
138
202
  // Register the outgoing value so its (possibly late) prop echo is
139
203
  // recognised and never rewinds the mirror.
@@ -0,0 +1,25 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /**
3
+ * UX Wave 2 / Task 4 — singleton update channel from the `todo_set` tool
4
+ * (producer) to `<App>` (consumer, Task 5: Ctrl+T panel + turn-status
5
+ * strip). Mirror of approvalEmitter: the tool emits, App subscribes in a
6
+ * useEffect and pushes the list into its state.
7
+ *
8
+ * Event contract (Task 5 renders from this):
9
+ * todoEmitter.emit('update', todos) // todos: TodoItem[]
10
+ *
11
+ * Fire-and-forget by design — unlike askQuestionEmitter there is no
12
+ * resolve callback because nothing about the tool result depends on the
13
+ * UI. The tool reaches this singleton via `ctx.todoEmitter` (Task 5):
14
+ * top-level entry points (repl.tsx, one-shot.ts) wire it into their
15
+ * ToolContext; agent_run's child ctx DELIBERATELY omits it — same seam
16
+ * as chunkEmitter — so a subagent's todo_set is fully suppressed and can
17
+ * never clobber the parent's panel. With no emitter on the ctx, or no
18
+ * listener attached (classic REPL, one-shot, headless), the tool behaves
19
+ * identically: state still lands on session.todos, only the live panel
20
+ * is absent.
21
+ *
22
+ * Never spawn a sibling Ink root for the panel — that fights the main
23
+ * <App> for stdout and corrupts the Ink frame.
24
+ */
25
+ export const todoEmitter = new EventEmitter();