@pugi/cli 0.1.0-alpha.10

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -0,0 +1,142 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
+ const ACCENT_CYAN = 'cyan';
6
+ const ACCENT_RED = 'red';
7
+ /**
8
+ * P2-1 (triple-review 2026-05-24): both `userCode` and
9
+ * `verificationUrl` originate from a server response and would render
10
+ * verbatim into Ink `<Text>`. A hostile or compromised server could
11
+ * embed ANSI escape sequences (clear screen, cursor moves, hyperlink
12
+ * faking) and they would EXECUTE in the user's terminal. We whitelist
13
+ * the user code to the documented alphabet (uppercase letters, digits,
14
+ * dashes) and clamp the URL to the same safe-http guard used by the
15
+ * auto-open helper. Anything outside the whitelist renders as a
16
+ * `<invalid>` placeholder so the visual contract stays intact while
17
+ * the dangerous bytes never reach the terminal.
18
+ */
19
+ const USER_CODE_PATTERN = /^[A-Z0-9-]{1,16}$/;
20
+ function sanitizeUserCode(code) {
21
+ return USER_CODE_PATTERN.test(code) ? code : '<invalid>';
22
+ }
23
+ function hasControlChar(input) {
24
+ // Reject any C0 (0x00-0x1F, 0x7F) or C1 (0x80-0x9F) control byte.
25
+ // ESC (0x1B) is the gateway character for every ANSI escape sequence
26
+ // we care about (CSI, OSC, hyperlink), and CSI alone (0x9B) is the
27
+ // C1 single-byte form some terminals still honour.
28
+ for (let i = 0; i < input.length; i += 1) {
29
+ const code = input.charCodeAt(i);
30
+ if (code <= 0x1f)
31
+ return true;
32
+ if (code === 0x7f)
33
+ return true;
34
+ if (code >= 0x80 && code <= 0x9f)
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ function sanitizeDisplayUrl(url) {
40
+ // Reject control characters BEFORE handing to `new URL`. The URL
41
+ // parser preserves ESC bytes inside the path, so a server response
42
+ // with an embedded escape would otherwise round-trip into the Ink
43
+ // render and execute against the user terminal.
44
+ if (hasControlChar(url))
45
+ return '<invalid URL>';
46
+ try {
47
+ const parsed = new URL(url);
48
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
49
+ return '<invalid URL>';
50
+ }
51
+ return url;
52
+ }
53
+ catch {
54
+ return '<invalid URL>';
55
+ }
56
+ }
57
+ /**
58
+ * Pugi device-flow Ink view. Stateless w.r.t. the network — the host
59
+ * supplies `status` and we render the matching frame.
60
+ */
61
+ export function DeviceFlow(props) {
62
+ const copiedFlashMs = props.copiedFlashMs ?? 800;
63
+ const spinnerIntervalMs = props.spinnerIntervalMs ?? 120;
64
+ const [copied, setCopied] = useState(false);
65
+ const [spinnerFrame, setSpinnerFrame] = useState(0);
66
+ useInput((input, key) => {
67
+ if (key.escape) {
68
+ props.onCancel();
69
+ return;
70
+ }
71
+ if (input === 'c' || input === 'C') {
72
+ // The host owns the clipboard side effect. The component just
73
+ // signals the intent and surfaces the "Copied!" flash.
74
+ props.onCopy();
75
+ setCopied(true);
76
+ return;
77
+ }
78
+ if (key.return && props.status.kind === 'success') {
79
+ props.onContinue();
80
+ return;
81
+ }
82
+ });
83
+ // Spinner pulse — only while polling. Pausing the interval on
84
+ // success/failure avoids a no-op timer surviving past the final
85
+ // render frame.
86
+ useEffect(() => {
87
+ if (props.status.kind !== 'polling')
88
+ return;
89
+ const handle = setInterval(() => {
90
+ setSpinnerFrame((current) => (current + 1) % SPINNER_FRAMES.length);
91
+ }, spinnerIntervalMs);
92
+ return () => clearInterval(handle);
93
+ }, [props.status.kind, spinnerIntervalMs]);
94
+ // "Copied!" auto-clear so the chip flashes briefly without sticking.
95
+ useEffect(() => {
96
+ if (!copied)
97
+ return;
98
+ const handle = setTimeout(() => setCopied(false), copiedFlashMs);
99
+ return () => clearTimeout(handle);
100
+ }, [copied, copiedFlashMs]);
101
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: ACCENT_CYAN, children: "Authorize Pugi CLI" }) }), _jsx(CodeChip, { code: sanitizeUserCode(props.userCode) }), _jsx(BrowserHintRow, { opened: props.browserOpened, url: sanitizeDisplayUrl(props.verificationUrl), copied: copied }), _jsx(Box, { marginTop: 1, children: _jsx(StatusRow, { status: props.status, spinnerFrame: spinnerFrame }) }), _jsx(Box, { marginTop: 1, children: _jsx(FooterHint, { status: props.status }) })] }));
102
+ }
103
+ /**
104
+ * Centered short-code "chip". The Pugi device-flow user code is
105
+ * always the 9-character "XXXX-XXXX" form (4 + dash + 4) generated by
106
+ * the Anvil device-flow start endpoint — we render it in cyan-bold so
107
+ * the user's eye lands on it instantly when comparing against the
108
+ * cabinet page.
109
+ */
110
+ function CodeChip({ code }) {
111
+ return (_jsx(Box, { marginTop: 1, justifyContent: "center", children: _jsx(Text, { bold: true, color: ACCENT_CYAN, children: ` ${code} ` }) }));
112
+ }
113
+ function BrowserHintRow({ opened, url, copied, }) {
114
+ if (copied) {
115
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: ACCENT_CYAN, children: "Copied!" }) }));
116
+ }
117
+ if (opened) {
118
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Browser opened to" }), _jsx(Text, { dimColor: true, children: url })] }));
119
+ }
120
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Browser didn't open? Use the URL below to sign in (c to copy)" }), _jsx(Text, { children: url })] }));
121
+ }
122
+ function StatusRow({ status, spinnerFrame, }) {
123
+ if (status.kind === 'polling') {
124
+ const glyph = SPINNER_FRAMES[spinnerFrame] ?? SPINNER_FRAMES[0];
125
+ return (_jsxs(Text, { children: [_jsx(Text, { color: ACCENT_CYAN, children: glyph }), _jsx(Text, { children: ' Waiting for browser approval…' })] }));
126
+ }
127
+ if (status.kind === 'success') {
128
+ return (_jsxs(Text, { children: [_jsx(Text, { color: ACCENT_CYAN, children: '✓ ' }), _jsx(Text, { color: ACCENT_CYAN, children: `Logged in as ${status.principalLabel}` })] }));
129
+ }
130
+ // failure
131
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: ACCENT_RED, children: '✗ ' }), _jsx(Text, { color: ACCENT_RED, children: status.reason })] }), status.hint ? _jsx(Text, { dimColor: true, children: status.hint }) : null] }));
132
+ }
133
+ function FooterHint({ status }) {
134
+ if (status.kind === 'success') {
135
+ return _jsx(Text, { dimColor: true, children: "Press Enter to continue\u2026" });
136
+ }
137
+ if (status.kind === 'failure') {
138
+ return _jsx(Text, { dimColor: true, children: "Esc to dismiss \u00B7 Run `pugi login` again to retry" });
139
+ }
140
+ return _jsx(Text, { dimColor: true, children: "Esc to cancel \u00B7 c to copy URL" });
141
+ }
142
+ //# sourceMappingURL=device-flow.js.map
@@ -0,0 +1,474 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * REPL input box - Sprint α6.14 (REPL UX P0 wave 1).
4
+ *
5
+ * Bordered, cursor-aware input box matching Claude Code / Codex CLI
6
+ * aesthetics. Layered upgrade over α5.7:
7
+ *
8
+ * - Top divider (full-width cyan rule) anchors the input below the
9
+ * main pane and gives the operator a clear visual seam.
10
+ * - Rounded cyan border wraps the prompt + line + cursor.
11
+ * - Cursor follows the caret position (left/right arrows + Home/End
12
+ * reposition without losing the trailing text).
13
+ * - Single-arrow `›` prompt in brand cyan; dim continuation prompt
14
+ * `┊` when the input wraps past the available width so the operator
15
+ * sees that they are still inside one logical line.
16
+ * - History navigation (↑/↓), slash palette inline suggestion, Esc
17
+ * cancel, Ctrl+C ×2 exit remain wired identically to α5.7.
18
+ *
19
+ * State that belongs in this component:
20
+ * - `line` (string) - current buffer
21
+ * - `cursor` (number) - caret offset into `line`
22
+ * - `history` (string[]) - session-local; persistence lands in
23
+ * commit 2 (per-workspace JSONL).
24
+ * - `historyIndex` (number) - -1 when not navigating
25
+ *
26
+ * Brand voice gate: no forbidden words. ASCII-only glyphs.
27
+ */
28
+ import { useEffect, useMemo, useRef, useState } from 'react';
29
+ import { Box, Text, useInput, useStdout } from 'ink';
30
+ import { append as appendHistory, read as readHistory, } from '../core/repl/history.js';
31
+ import { applyQuery, currentBrief, cycle, initialSearchState, } from '../core/repl/history-search.js';
32
+ import { SlashPalette, completePalette, filterPalette, } from './slash-palette.js';
33
+ import { EMPTY_KILL_RING, killToLineEnd, killToLineStart, killWordBackward, yankAtCursor, } from '../core/repl/kill-ring.js';
34
+ import { readClipboard } from '../core/repl/clipboard-read.js';
35
+ const CTRL_C_DOUBLE_TAP_MS = 1_000;
36
+ /** Width subtracted from the terminal width so the border + padding fit. */
37
+ const FRAME_OVERHEAD_COLUMNS = 4;
38
+ /** Fallback width when ink cannot read stdout (e.g. test harness). */
39
+ const FALLBACK_COLUMNS = 80;
40
+ /** Cursor blink interval (ms). 530ms matches the GNOME / iTerm2 default. */
41
+ const CURSOR_BLINK_MS = 530;
42
+ /**
43
+ * Strip ANSI escape sequences, bracketed-paste markers, and C0 control
44
+ * bytes (except `\n` and `\t`) from clipboard text before splicing it
45
+ * into the buffer. Keystroke input never reaches the input box with
46
+ * escapes (ink filters them), so the buffer assumes printable text +
47
+ * the two whitespace forms we preserve. Clipboard text bypasses ink's
48
+ * filter, so we re-apply the same constraint here to neutralise any
49
+ * escape sequence a hostile or accidental copy might carry into the
50
+ * operator's terminal.
51
+ */
52
+ export function sanitiseClipboardText(raw) {
53
+ return raw
54
+ .replace(/\x1b\[200~/g, '')
55
+ .replace(/\x1b\[201~/g, '')
56
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
57
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
58
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
59
+ }
60
+ export function InputBox(props) {
61
+ // Seed history from disk on mount so the operator can ↑ to last
62
+ // session's brief immediately. Memoised so we read the file once per
63
+ // workspace, not on every render.
64
+ const seededHistory = useMemo(() => {
65
+ if (!props.workspaceSlug)
66
+ return [];
67
+ const entries = readHistory({
68
+ home: props.historyHome,
69
+ workspaceSlug: props.workspaceSlug,
70
+ });
71
+ return entries.map((e) => e.brief);
72
+ }, [props.workspaceSlug, props.historyHome]);
73
+ const [line, setLine] = useState(props.initial ?? '');
74
+ const [cursor, setCursor] = useState(props.initial?.length ?? 0);
75
+ const [history, setHistory] = useState(seededHistory);
76
+ const [historyIndex, setHistoryIndex] = useState(-1);
77
+ const [lastCtrlCAt, setLastCtrlCAt] = useState(undefined);
78
+ const [cursorVisible, setCursorVisible] = useState(true);
79
+ // Ctrl+R / Ctrl+S reverse-search mode. Undefined when idle, a
80
+ // HistorySearchState while the operator is searching.
81
+ const [search, setSearch] = useState(undefined);
82
+ // Draft preserved while the operator searches so Esc returns the
83
+ // pre-search buffer instead of dropping it on the floor.
84
+ const [draftBeforeSearch, setDraftBeforeSearch] = useState('');
85
+ // Slash palette state. When the buffer starts with `/` the palette
86
+ // renders, ↑/↓ selects a row, Tab autocompletes, Enter runs.
87
+ const [paletteIndex, setPaletteIndex] = useState(0);
88
+ // Operator-toggled palette suppression. Esc closes the palette
89
+ // without clearing the buffer (useful for `/help` text dispatch).
90
+ const [paletteSuppressed, setPaletteSuppressed] = useState(false);
91
+ // Readline-style kill ring backing Ctrl+U / Ctrl+K / Ctrl+W / Ctrl+Y.
92
+ const [killRing, setKillRing] = useState(EMPTY_KILL_RING);
93
+ // Soft counter; bumping forces Ink to re-render the surrounding
94
+ // panes when Ctrl+L wipes the terminal (the parent React tree is
95
+ // otherwise stable and would not redraw on a stdout.write alone).
96
+ const [, setRedrawTick] = useState(0);
97
+ const now = props.now ?? Date.now;
98
+ const { stdout } = useStdout();
99
+ const columns = stdout?.columns ?? FALLBACK_COLUMNS;
100
+ const innerWidth = Math.max(20, columns - FRAME_OVERHEAD_COLUMNS);
101
+ // Refs mirror the latest committed line + cursor so the async clipboard
102
+ // paste handler can splice against current values without re-entering
103
+ // React's setState updater (which strict mode invokes twice and would
104
+ // double-insert the pasted text). The setState updater pattern used to
105
+ // capture latest line/cursor (see Codex P2 below) is correct under
106
+ // single-render mode but broken under React 18 strict mode + the
107
+ // setLine-inside-setCursor nesting we ended up with. Refs are the
108
+ // canonical escape hatch for "use the most recently committed value
109
+ // inside a one-shot async callback".
110
+ const lineRef = useRef(line);
111
+ const cursorRef = useRef(cursor);
112
+ useEffect(() => {
113
+ lineRef.current = line;
114
+ }, [line]);
115
+ useEffect(() => {
116
+ cursorRef.current = cursor;
117
+ }, [cursor]);
118
+ // Soft blink so the operator can spot where typing will land. Ink
119
+ // re-renders only when state changes, so a 530ms toggle is the cheapest
120
+ // honest cursor we can show without a custom raw-mode dance. Tests
121
+ // disable the interval so node:test does not see a dangling timer.
122
+ const blinkEnabled = props.blinkCursor ?? true;
123
+ useEffect(() => {
124
+ if (!blinkEnabled)
125
+ return undefined;
126
+ const interval = setInterval(() => {
127
+ setCursorVisible((prev) => !prev);
128
+ }, CURSOR_BLINK_MS);
129
+ return () => clearInterval(interval);
130
+ }, [blinkEnabled]);
131
+ useInput((input, key) => {
132
+ if (key.ctrl && input === 'c') {
133
+ const t = now();
134
+ if (typeof lastCtrlCAt === 'number' && t - lastCtrlCAt <= CTRL_C_DOUBLE_TAP_MS) {
135
+ props.onExit();
136
+ return;
137
+ }
138
+ setLastCtrlCAt(t);
139
+ setLine('');
140
+ setCursor(0);
141
+ setSearch(undefined);
142
+ return;
143
+ }
144
+ // Search-mode key handling. Ctrl+R / Ctrl+S cycle, Enter accepts,
145
+ // Esc cancels (restoring the pre-search draft), backspace shortens
146
+ // the query, typed characters extend it.
147
+ if (search) {
148
+ if (key.ctrl && input === 'r') {
149
+ setSearch((s) => (s ? cycle(s, 1) : s));
150
+ return;
151
+ }
152
+ if (key.ctrl && input === 's') {
153
+ setSearch((s) => (s ? cycle(s, -1) : s));
154
+ return;
155
+ }
156
+ if (key.escape) {
157
+ setSearch(undefined);
158
+ setLine(draftBeforeSearch);
159
+ setCursor(draftBeforeSearch.length);
160
+ return;
161
+ }
162
+ if (key.return) {
163
+ const picked = currentBrief(search);
164
+ setSearch(undefined);
165
+ if (picked !== null) {
166
+ setLine(picked);
167
+ setCursor(picked.length);
168
+ }
169
+ else {
170
+ setLine(draftBeforeSearch);
171
+ setCursor(draftBeforeSearch.length);
172
+ }
173
+ return;
174
+ }
175
+ if (key.backspace || key.delete) {
176
+ const nextQuery = search.query.slice(0, -1);
177
+ setSearch(applyQuery(search, nextQuery, history));
178
+ return;
179
+ }
180
+ if (input && !key.meta && !key.ctrl) {
181
+ const nextQuery = search.query + input;
182
+ setSearch(applyQuery(search, nextQuery, history));
183
+ return;
184
+ }
185
+ // Any other key inside search mode is ignored - the operator can
186
+ // still escape with Esc or cancel via Ctrl+C handled above.
187
+ return;
188
+ }
189
+ if (key.ctrl && input === 'r') {
190
+ // Enter reverse-search mode. Stash the current buffer so Esc
191
+ // restores it untouched.
192
+ setDraftBeforeSearch(line);
193
+ setSearch(initialSearchState(history));
194
+ return;
195
+ }
196
+ if (key.ctrl && input === 's') {
197
+ // Symmetric forward search entry. Cycle direction differs once
198
+ // active; entry seeds the same browse state.
199
+ setDraftBeforeSearch(line);
200
+ setSearch(initialSearchState(history));
201
+ return;
202
+ }
203
+ // Readline-style kill ring shortcuts. All four kills push the
204
+ // removed slice onto the ring; Ctrl+Y yanks the most recent.
205
+ if (key.ctrl && input === 'u') {
206
+ const next = killToLineStart(line, cursor, killRing);
207
+ setLine(next.line);
208
+ setCursor(next.cursor);
209
+ setKillRing(next.ring);
210
+ return;
211
+ }
212
+ if (key.ctrl && input === 'k') {
213
+ const next = killToLineEnd(line, cursor, killRing);
214
+ setLine(next.line);
215
+ setCursor(next.cursor);
216
+ setKillRing(next.ring);
217
+ return;
218
+ }
219
+ if (key.ctrl && input === 'w') {
220
+ const next = killWordBackward(line, cursor, killRing);
221
+ setLine(next.line);
222
+ setCursor(next.cursor);
223
+ setKillRing(next.ring);
224
+ return;
225
+ }
226
+ if (key.ctrl && input === 'y') {
227
+ const next = yankAtCursor(line, cursor, killRing);
228
+ setLine(next.line);
229
+ setCursor(next.cursor);
230
+ return;
231
+ }
232
+ if (key.ctrl && input === 'l') {
233
+ // Clear the terminal viewport. ANSI 2J = erase entire screen,
234
+ // 0;0H = home cursor. We bump the redraw tick so Ink repaints
235
+ // the surrounding REPL panes on the next frame; without this
236
+ // ink keeps its diff buffer and we get a half-wiped screen.
237
+ if (stdout && typeof stdout.write === 'function') {
238
+ stdout.write('\x1b[2J\x1b[0;0H');
239
+ }
240
+ setRedrawTick((t) => t + 1);
241
+ return;
242
+ }
243
+ if (key.ctrl && input === 'v') {
244
+ // Ctrl+V: read the OS clipboard and insert at cursor. The
245
+ // platform helper (pbpaste / wl-paste / xclip -o / Get-Clipboard)
246
+ // is async + best-effort; on failure the buffer is left
247
+ // untouched and the operator falls back to terminal paste
248
+ // (Cmd+V on macOS, right-click on Linux).
249
+ //
250
+ // Multi-line pastes preserve interior newlines so the operator
251
+ // sees the full text in the buffer; the Enter-on-buffered-LF
252
+ // case is left to the operator (they press Enter when ready).
253
+ //
254
+ // Refs (lineRef / cursorRef) hold the latest committed values so
255
+ // the splice runs against the operator's most recent edits even
256
+ // if they kept typing while pbpaste was in flight. The previous
257
+ // setLine-inside-setCursor nesting double-inserted under React
258
+ // 18 strict mode because each setState updater runs twice in
259
+ // development. Claude P1.
260
+ //
261
+ // sanitiseClipboardText strips ANSI escapes, bracketed-paste
262
+ // markers, and C0 control bytes so a hostile or accidental copy
263
+ // cannot inject terminal-control sequences. Claude P2.
264
+ readClipboard()
265
+ .then((res) => {
266
+ if (res.text === null)
267
+ return;
268
+ const sanitised = sanitiseClipboardText(res.text);
269
+ if (sanitised.length === 0)
270
+ return;
271
+ const currentLine = lineRef.current;
272
+ const currentCursor = cursorRef.current;
273
+ const newLine = currentLine.slice(0, currentCursor) +
274
+ sanitised +
275
+ currentLine.slice(currentCursor);
276
+ const newCursor = currentCursor + sanitised.length;
277
+ setLine(newLine);
278
+ setCursor(newCursor);
279
+ })
280
+ .catch(() => {
281
+ // Helper already swallows errors; the catch here is belt +
282
+ // suspenders so a TypeError never bubbles into the render.
283
+ });
284
+ return;
285
+ }
286
+ // Compute palette visibility FIRST so Enter can run the focused
287
+ // palette row instead of submitting a partial `/he` as unknown.
288
+ // Palette is hidden when the operator pressed Esc on it.
289
+ const palette = !paletteSuppressed ? filterPalette(line) : { rows: [], totalBeforeLimit: 0 };
290
+ const paletteOpen = palette.rows.length > 0;
291
+ const paletteFocusedIndex = palette.rows.length === 0
292
+ ? 0
293
+ : Math.min(paletteIndex, palette.rows.length - 1);
294
+ if (key.return) {
295
+ // When the palette is open, Enter expands the buffer to the
296
+ // complete command name (preserving any args past the head)
297
+ // before submission. Codex review P2.
298
+ let payload = line;
299
+ if (paletteOpen) {
300
+ const completed = completePalette(line, palette.rows, paletteFocusedIndex);
301
+ if (completed !== null)
302
+ payload = completed;
303
+ }
304
+ const trimmed = payload.trim();
305
+ if (trimmed.length > 0) {
306
+ setHistory((prev) => {
307
+ // In-session dedup of consecutive identical entries mirrors
308
+ // the on-disk dedup so ↑ never shows duplicates.
309
+ if (prev[prev.length - 1] === trimmed)
310
+ return prev;
311
+ return [...prev, trimmed];
312
+ });
313
+ setHistoryIndex(-1);
314
+ if (props.workspaceSlug) {
315
+ // Fire-and-forget. Helper never throws - it returns null
316
+ // when storage is unavailable and history degrades to
317
+ // session-local for this turn.
318
+ appendHistory({
319
+ home: props.historyHome,
320
+ workspaceSlug: props.workspaceSlug,
321
+ brief: trimmed,
322
+ });
323
+ }
324
+ props.onSubmit(trimmed);
325
+ }
326
+ setLine('');
327
+ setCursor(0);
328
+ setPaletteSuppressed(false);
329
+ setPaletteIndex(0);
330
+ return;
331
+ }
332
+ if (key.escape) {
333
+ if (paletteOpen) {
334
+ // Close the palette without clearing the buffer so the operator
335
+ // can still send `/help` as plain text if they want.
336
+ setPaletteSuppressed(true);
337
+ return;
338
+ }
339
+ setLine('');
340
+ setCursor(0);
341
+ setHistoryIndex(-1);
342
+ return;
343
+ }
344
+ if (key.tab && paletteOpen) {
345
+ const completed = completePalette(line, palette.rows, paletteFocusedIndex);
346
+ if (completed !== null) {
347
+ setLine(completed);
348
+ setCursor(completed.length);
349
+ }
350
+ return;
351
+ }
352
+ if (key.upArrow) {
353
+ if (paletteOpen) {
354
+ setPaletteIndex((i) => (palette.rows.length === 0 ? 0 : (i - 1 + palette.rows.length) % palette.rows.length));
355
+ return;
356
+ }
357
+ if (history.length === 0)
358
+ return;
359
+ const nextIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
360
+ const entry = history[nextIndex] ?? '';
361
+ setHistoryIndex(nextIndex);
362
+ setLine(entry);
363
+ setCursor(entry.length);
364
+ return;
365
+ }
366
+ if (key.downArrow) {
367
+ if (paletteOpen) {
368
+ setPaletteIndex((i) => (palette.rows.length === 0 ? 0 : (i + 1) % palette.rows.length));
369
+ return;
370
+ }
371
+ if (history.length === 0)
372
+ return;
373
+ if (historyIndex === -1)
374
+ return;
375
+ const nextIndex = historyIndex + 1;
376
+ if (nextIndex >= history.length) {
377
+ setHistoryIndex(-1);
378
+ setLine('');
379
+ setCursor(0);
380
+ return;
381
+ }
382
+ const entry = history[nextIndex] ?? '';
383
+ setHistoryIndex(nextIndex);
384
+ setLine(entry);
385
+ setCursor(entry.length);
386
+ return;
387
+ }
388
+ if (key.leftArrow) {
389
+ setCursor((c) => Math.max(0, c - 1));
390
+ return;
391
+ }
392
+ if (key.rightArrow) {
393
+ setCursor((c) => Math.min(line.length, c + 1));
394
+ return;
395
+ }
396
+ if (key.backspace || key.delete) {
397
+ if (cursor === 0)
398
+ return;
399
+ // Read cursor via ref inside the updater so the slice indices come
400
+ // from the latest committed value, not the closure-captured one.
401
+ // Same React-18 strict-mode race that bit the Ctrl+V paste path
402
+ // (Claude P1 on PR #335 wave 1): updaters run twice under strict
403
+ // mode, and a stale closure `cursor` produces a double-edit on the
404
+ // second invocation. cursorRef is the canonical fix — do NOT
405
+ // re-introduce closure `cursor` inside setLine updaters.
406
+ const cursorAtPress = cursorRef.current;
407
+ setLine((prev) => prev.slice(0, cursorAtPress - 1) + prev.slice(cursorAtPress));
408
+ setCursor((c) => Math.max(0, c - 1));
409
+ // Backspacing past `/` re-opens the palette next render.
410
+ setPaletteSuppressed(false);
411
+ setPaletteIndex(0);
412
+ return;
413
+ }
414
+ if (input && !key.meta && !key.ctrl) {
415
+ // Ink delivers one or more characters per event; concatenate at
416
+ // the cursor without filtering so non-Latin + emoji sequences
417
+ // (the operator's own brief copy) survive paste.
418
+ //
419
+ // Bracketed-paste mode markers (`ESC[200~ ... ESC[201~`) may
420
+ // arrive when a modern terminal pastes multi-line text. Strip
421
+ // them inline so the buffer stays clean; the spec calls out
422
+ // disabling Enter-as-submit during the burst, but Ink's
423
+ // useInput delivers the whole paste as one event so the burst
424
+ // is atomic and Enter cannot fire mid-paste here.
425
+ //
426
+ // Ink's escape-sequence parser may consume the leading ESC for
427
+ // unknown CSI sequences, so we match both forms (with + without
428
+ // the leading ESC byte) to be safe across terminal emulators.
429
+ const stripped = input
430
+ .replace(/\x1b\[200~/g, '')
431
+ .replace(/\x1b\[201~/g, '')
432
+ .replace(/\[200~/g, '')
433
+ .replace(/\[201~/g, '');
434
+ if (stripped.length === 0)
435
+ return;
436
+ // Read cursor via ref inside the updater so the splice indices come
437
+ // from the latest committed value, not the closure-captured one.
438
+ // Same React-18 strict-mode race that bit the Ctrl+V paste path
439
+ // (Claude P1 on PR #335 wave 1): updaters run twice under strict
440
+ // mode, and a stale closure `cursor` produces a duplicated insert
441
+ // on the second invocation. cursorRef is the canonical fix — do
442
+ // NOT re-introduce closure `cursor` inside setLine updaters.
443
+ const cursorAtPress = cursorRef.current;
444
+ setLine((prev) => prev.slice(0, cursorAtPress) + stripped + prev.slice(cursorAtPress));
445
+ setCursor((c) => c + stripped.length);
446
+ // Typing a new char un-suppresses the palette and resets focus.
447
+ setPaletteSuppressed(false);
448
+ setPaletteIndex(0);
449
+ }
450
+ });
451
+ const paletteView = !search && !paletteSuppressed ? filterPalette(line) : { rows: [], totalBeforeLimit: 0 };
452
+ // Clamp focus to current row count so a shrink (e.g. operator typed
453
+ // a char that narrowed the list) does not point off-the-end.
454
+ const clampedPaletteIndex = paletteView.rows.length === 0
455
+ ? 0
456
+ : Math.min(paletteIndex, paletteView.rows.length - 1);
457
+ const divider = '─'.repeat(innerWidth);
458
+ const focusedMatch = search ? search.matches[search.focusedIndex] : undefined;
459
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", dimColor: true, children: divider }), _jsx(Box, { paddingX: 1, flexDirection: "column", children: search ? (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '(reverse-i-search) ' }), _jsx(Text, { children: `\`${search.query}\`: ` }), _jsx(Text, { color: "yellow", children: focusedMatch ? focusedMatch.brief : '(no match)' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `Ctrl+R next · Ctrl+S prev · Enter accept · Esc cancel · ${search.matches.length} match${search.matches.length === 1 ? '' : 'es'}` }) })] })) : (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: renderLineWithCursor(line, cursor, cursorVisible) })] })) }), _jsx(Text, { color: "cyan", dimColor: true, children: divider }), line.length > innerWidth - 4 ? (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: '┊ ' }), _jsx(Text, { dimColor: true, children: 'line wraps - Enter still submits' })] })) : null, _jsx(SlashPalette, { rows: paletteView.rows, focusedIndex: clampedPaletteIndex, totalBeforeLimit: paletteView.totalBeforeLimit }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · Ctrl+R search · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
460
+ }
461
+ /**
462
+ * Render the line with the cursor glyph inserted at `cursor`. The cursor
463
+ * glyph is an inverted block when visible, a single space when blinked
464
+ * off - keeping the rendered width stable so the surrounding border
465
+ * does not jitter.
466
+ */
467
+ function renderLineWithCursor(line, cursor, visible) {
468
+ const safeCursor = Math.max(0, Math.min(line.length, cursor));
469
+ const before = line.slice(0, safeCursor);
470
+ const after = line.slice(safeCursor);
471
+ const caret = visible ? '█' : ' ';
472
+ return `${before}${caret}${after}`;
473
+ }
474
+ //# sourceMappingURL=input-box.js.map