@pugi/cli 0.1.0-alpha.6 → 0.1.0-alpha.7
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/core/auto-open-browser.js +128 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +44 -0
- package/dist/core/repl/slash-commands.js +8 -0
- package/dist/core/settings.js +13 -0
- package/dist/runtime/cli.js +392 -66
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/registry.js +1 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +410 -27
- package/dist/tui/render.js +57 -0
- package/dist/tui/repl-render.js +1 -1
- package/dist/tui/repl.js +8 -1
- package/dist/tui/slash-palette.js +69 -0
- package/dist/tui/update-banner.js +8 -0
- package/package.json +7 -2
package/dist/tui/input-box.js
CHANGED
|
@@ -1,30 +1,133 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
* REPL input box - Sprint α
|
|
3
|
+
* REPL input box - Sprint α6.14 (REPL UX P0 wave 1).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - slash command palette suggestion when the line starts with `/`
|
|
8
|
-
* - Enter submits the line
|
|
9
|
-
* - Esc clears the current line
|
|
10
|
-
* - Ctrl+C once cancels current line; twice within 1s exits the REPL
|
|
5
|
+
* Bordered, cursor-aware input box matching Claude Code / Codex CLI
|
|
6
|
+
* aesthetics. Layered upgrade over α5.7:
|
|
11
7
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* the
|
|
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.
|
|
17
27
|
*/
|
|
18
|
-
import { useState } from 'react';
|
|
19
|
-
import { Box, Text, useInput } from 'ink';
|
|
20
|
-
import {
|
|
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';
|
|
21
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
|
+
}
|
|
22
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]);
|
|
23
73
|
const [line, setLine] = useState(props.initial ?? '');
|
|
24
|
-
const [
|
|
74
|
+
const [cursor, setCursor] = useState(props.initial?.length ?? 0);
|
|
75
|
+
const [history, setHistory] = useState(seededHistory);
|
|
25
76
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
26
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);
|
|
27
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]);
|
|
28
131
|
useInput((input, key) => {
|
|
29
132
|
if (key.ctrl && input === 'c') {
|
|
30
133
|
const t = now();
|
|
@@ -34,32 +137,237 @@ export function InputBox(props) {
|
|
|
34
137
|
}
|
|
35
138
|
setLastCtrlCAt(t);
|
|
36
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));
|
|
37
194
|
return;
|
|
38
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);
|
|
39
294
|
if (key.return) {
|
|
40
|
-
|
|
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();
|
|
41
305
|
if (trimmed.length > 0) {
|
|
42
|
-
setHistory((prev) =>
|
|
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
|
+
});
|
|
43
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
|
+
}
|
|
44
324
|
props.onSubmit(trimmed);
|
|
45
325
|
}
|
|
46
326
|
setLine('');
|
|
327
|
+
setCursor(0);
|
|
328
|
+
setPaletteSuppressed(false);
|
|
329
|
+
setPaletteIndex(0);
|
|
47
330
|
return;
|
|
48
331
|
}
|
|
49
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
|
+
}
|
|
50
339
|
setLine('');
|
|
340
|
+
setCursor(0);
|
|
51
341
|
setHistoryIndex(-1);
|
|
52
342
|
return;
|
|
53
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
|
+
}
|
|
54
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
|
+
}
|
|
55
357
|
if (history.length === 0)
|
|
56
358
|
return;
|
|
57
359
|
const nextIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1);
|
|
360
|
+
const entry = history[nextIndex] ?? '';
|
|
58
361
|
setHistoryIndex(nextIndex);
|
|
59
|
-
setLine(
|
|
362
|
+
setLine(entry);
|
|
363
|
+
setCursor(entry.length);
|
|
60
364
|
return;
|
|
61
365
|
}
|
|
62
366
|
if (key.downArrow) {
|
|
367
|
+
if (paletteOpen) {
|
|
368
|
+
setPaletteIndex((i) => (palette.rows.length === 0 ? 0 : (i + 1) % palette.rows.length));
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
63
371
|
if (history.length === 0)
|
|
64
372
|
return;
|
|
65
373
|
if (historyIndex === -1)
|
|
@@ -68,24 +376,99 @@ export function InputBox(props) {
|
|
|
68
376
|
if (nextIndex >= history.length) {
|
|
69
377
|
setHistoryIndex(-1);
|
|
70
378
|
setLine('');
|
|
379
|
+
setCursor(0);
|
|
71
380
|
return;
|
|
72
381
|
}
|
|
382
|
+
const entry = history[nextIndex] ?? '';
|
|
73
383
|
setHistoryIndex(nextIndex);
|
|
74
|
-
setLine(
|
|
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));
|
|
75
394
|
return;
|
|
76
395
|
}
|
|
77
396
|
if (key.backspace || key.delete) {
|
|
78
|
-
|
|
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);
|
|
79
412
|
return;
|
|
80
413
|
}
|
|
81
414
|
if (input && !key.meta && !key.ctrl) {
|
|
82
|
-
// Ink
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
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);
|
|
86
449
|
}
|
|
87
450
|
});
|
|
88
|
-
const
|
|
89
|
-
|
|
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, { borderStyle: "round", borderColor: "cyan", 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) })] })) }), 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}`;
|
|
90
473
|
}
|
|
91
474
|
//# sourceMappingURL=input-box.js.map
|
package/dist/tui/render.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { render } from 'ink';
|
|
3
|
+
import { DeviceFlow } from './device-flow.js';
|
|
3
4
|
import { LoginPicker } from './login-picker.js';
|
|
4
5
|
import { Splash } from './splash.js';
|
|
5
6
|
import { collectSplashData } from './splash-data.js';
|
|
@@ -65,4 +66,60 @@ export function renderLoginPicker(apiUrl) {
|
|
|
65
66
|
}));
|
|
66
67
|
});
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Mount `<DeviceFlow />` on a TTY and return a handle the host uses to
|
|
71
|
+
* drive the frame. Mirrors `renderLoginPicker`'s lifecycle: we
|
|
72
|
+
* deliberately do NOT call `waitUntilExit()` after unmount (some
|
|
73
|
+
* shimmed-stdin test harnesses never settle that promise — the next
|
|
74
|
+
* code path in cli.ts restores stdin state on its own).
|
|
75
|
+
*/
|
|
76
|
+
export function renderDeviceFlow(options) {
|
|
77
|
+
let settled = false;
|
|
78
|
+
let resolveDone = () => undefined;
|
|
79
|
+
let rejectDone = () => undefined;
|
|
80
|
+
const donePromise = new Promise((resolveFn, rejectFn) => {
|
|
81
|
+
resolveDone = resolveFn;
|
|
82
|
+
rejectDone = rejectFn;
|
|
83
|
+
});
|
|
84
|
+
// Per the spec, the polling status drives the visible spinner; the
|
|
85
|
+
// host pushes status updates via `setStatus`. We use a tiny stateful
|
|
86
|
+
// wrapper component so React owns the re-render lifecycle (calling
|
|
87
|
+
// `instance.rerender` with a new element triggers a clean diff).
|
|
88
|
+
let currentStatus = { kind: 'polling' };
|
|
89
|
+
const finish = (cb) => {
|
|
90
|
+
if (settled)
|
|
91
|
+
return;
|
|
92
|
+
settled = true;
|
|
93
|
+
instance.unmount();
|
|
94
|
+
setImmediate(cb);
|
|
95
|
+
};
|
|
96
|
+
const renderElement = () => React.createElement(DeviceFlow, {
|
|
97
|
+
verificationUrl: options.verificationUrl,
|
|
98
|
+
userCode: options.userCode,
|
|
99
|
+
browserOpened: options.browserOpened,
|
|
100
|
+
status: currentStatus,
|
|
101
|
+
onCopy: options.onCopy,
|
|
102
|
+
onCancel: () => {
|
|
103
|
+
finish(() => rejectDone(new LoginCancelledError()));
|
|
104
|
+
},
|
|
105
|
+
onContinue: () => {
|
|
106
|
+
finish(() => resolveDone());
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const instance = render(renderElement());
|
|
110
|
+
return {
|
|
111
|
+
setStatus(next) {
|
|
112
|
+
if (settled)
|
|
113
|
+
return;
|
|
114
|
+
currentStatus = next;
|
|
115
|
+
instance.rerender(renderElement());
|
|
116
|
+
},
|
|
117
|
+
unmount() {
|
|
118
|
+
finish(() => resolveDone());
|
|
119
|
+
},
|
|
120
|
+
done() {
|
|
121
|
+
return donePromise;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
68
125
|
//# sourceMappingURL=render.js.map
|
package/dist/tui/repl-render.js
CHANGED
|
@@ -37,7 +37,7 @@ export async function renderRepl(options) {
|
|
|
37
37
|
// Kick off the connect; the Repl renders the connecting state until
|
|
38
38
|
// the session pushes `connection: 'on_watch'` from the SSE onOpen.
|
|
39
39
|
void session.start();
|
|
40
|
-
const instance = render(React.createElement(Repl, { session }));
|
|
40
|
+
const instance = render(React.createElement(Repl, { session, updateBanner: options.updateBanner ?? null }));
|
|
41
41
|
try {
|
|
42
42
|
await instance.waitUntilExit();
|
|
43
43
|
}
|
package/dist/tui/repl.js
CHANGED
|
@@ -24,6 +24,8 @@ import { AgentTree } from './agent-tree.js';
|
|
|
24
24
|
import { ConversationPane } from './conversation-pane.js';
|
|
25
25
|
import { InputBox } from './input-box.js';
|
|
26
26
|
import { StatusBar } from './status-bar.js';
|
|
27
|
+
import { UpdateBanner } from './update-banner.js';
|
|
28
|
+
import { slugForCwd } from '../core/repl/history.js';
|
|
27
29
|
import { SLASH_COMMAND_HELP } from '../core/repl/slash-commands.js';
|
|
28
30
|
const TICK_INTERVAL_MS = 200;
|
|
29
31
|
const PULSE_INTERVAL_MS = 700;
|
|
@@ -94,7 +96,11 @@ export function Repl(props) {
|
|
|
94
96
|
setOverlay('none');
|
|
95
97
|
}
|
|
96
98
|
}, { isActive: overlay === 'help' || overlay === 'roster' });
|
|
97
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now
|
|
99
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow })) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, now: props.now,
|
|
100
|
+
// Slug from process.cwd() (full path) so two workspaces with
|
|
101
|
+
// the same basename do not share history. state.workspaceLabel
|
|
102
|
+
// is the basename only. Codex review P2.
|
|
103
|
+
workspaceSlug: slugForCwd(process.cwd()) })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase })] })] }));
|
|
98
104
|
}
|
|
99
105
|
function Header({ state }) {
|
|
100
106
|
return (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Pugi" }), _jsx(Text, { bold: true, color: "cyan", children: ".io" }), _jsx(Text, { dimColor: true, children: ` · workspace: ${state.workspaceLabel} · v${state.cliVersion} · ` }), _jsx(Text, { color: "cyan", children: state.connection === 'on_watch' ? 'on watch' : state.connection.replace('_', ' ') })] }));
|
|
@@ -129,6 +135,7 @@ function applyVerdictSideEffects(verdict, handlers) {
|
|
|
129
135
|
return;
|
|
130
136
|
case 'dispatch':
|
|
131
137
|
case 'stop':
|
|
138
|
+
case 'web':
|
|
132
139
|
case 'error':
|
|
133
140
|
case 'noop':
|
|
134
141
|
return;
|