@cspeach/cli 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +22 -9
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +189 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-resume.js +588 -33
- package/dist/config/loader.js +128 -4
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- package/dist/doctor/checks/system-roles.js +41 -0
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +25 -3
- package/dist/projects/extract-cca.js +3 -1
- package/dist/projects/extract-modernize.js +3 -1
- package/dist/projects/extract-plan.js +60 -6
- package/dist/projects/extract-test-coverage.js +3 -1
- package/dist/projects/extract-upgrade.js +3 -1
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +1 -1
- package/dist/projects/plan-run.js +137 -13
- package/dist/projects/plan-schema.js +73 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +26 -15
- package/dist/renderer/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +37 -0
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +5 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/post-turn-status.js +24 -4
- package/dist/repl/slash-completer.js +5 -0
- package/dist/repl.js +954 -83
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- package/dist/session/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +9 -3
- package/dist/skills/bundled-skills.js +59 -66
- package/dist/tools/approval.js +115 -7
- package/dist/tools/ask-question.js +304 -3
- package/dist/tools/extend-model/anchored-insert.js +604 -0
- package/dist/tools/extend-model/tool.js +162 -10
- package/dist/tools/fiori/fe-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +29 -3
- package/dist/tools/fiori/floorplan-map.js +19 -0
- package/dist/tools/fiori/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +328 -3
- package/dist/tools/local-build.js +11 -1
- package/dist/tools/sap-read.js +79 -11
- package/dist/tools/sap-write.js +24 -4
- package/dist/tools/snapshot.js +27 -1
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/todo.js +144 -0
- package/dist/ui/app.js +372 -19
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +109 -30
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +18 -2
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +72 -8
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +17 -7
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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: [
|
|
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
|
-
/**
|
|
53
|
-
|
|
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
|
+
}
|
package/dist/ui/text-input.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
114
|
+
renderedValue = displayValue.length > 0 ? '' : chalk.inverse(' ');
|
|
78
115
|
let i = 0;
|
|
79
|
-
for (const char of
|
|
80
|
-
renderedValue += i ===
|
|
116
|
+
for (const char of displayValue) {
|
|
117
|
+
renderedValue += i === displayCursor ? chalk.inverse(char) : char;
|
|
81
118
|
i++;
|
|
82
119
|
}
|
|
83
|
-
if (
|
|
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();
|