@pugi/cli 0.1.0-alpha.5 → 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.
@@ -1,30 +1,133 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * REPL input box - Sprint α5.7 (ADR-0056 acceptance #5, #9).
3
+ * REPL input box - Sprint α6.14 (REPL UX P0 wave 1).
4
4
  *
5
- * Single-line input with:
6
- * - history navigation (↑/↓)
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
- * The component owns the cursor + history state. Submission is passed
13
- * to the REPL root via the `onSubmit` callback; the root forwards to
14
- * `ReplSession.handleInput`. Ctrl+C double-tap is also surfaced via a
15
- * dedicated `onExit` callback so the REPL root can stop Ink and let
16
- * the runtime exit gracefully.
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 { matchSlashPrefix } from '../core/repl/slash-commands.js';
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 [history, setHistory] = useState([]);
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
- const trimmed = line.trim();
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) => [...prev, trimmed]);
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(history[nextIndex] ?? '');
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(history[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));
75
394
  return;
76
395
  }
77
396
  if (key.backspace || key.delete) {
78
- setLine((prev) => prev.slice(0, -1));
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's typing surface delivers one or more characters per
83
- // event; concatenate without filtering so non-Latin and emoji
84
- // sequences (the operator's own brief copy) survive paste.
85
- setLine((prev) => prev + input);
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 palette = line.startsWith('/') ? matchSlashPrefix(line) : [];
89
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: '› ' }), _jsx(Text, { children: line }), _jsx(Text, { color: "cyan", children: '_' })] }), palette.length > 0 ? (_jsx(Box, { flexDirection: "column", marginTop: 0, children: palette.map((row) => (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: ` /${row.name}${row.args ? ` ${row.args}` : ''}`.padEnd(22, ' ') }), _jsx(Text, { dimColor: true, children: row.gloss })] }, row.name))) })) : null, _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '↑/↓ history · / commands · Enter brief · Esc cancel · Ctrl+C ×2 exit' }) })] }));
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
@@ -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
@@ -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 })), _jsx(StatusBar, { connection: state.connection, activeAgentCount: countActive(state), tokensDownstreamTotal: state.tokensDownstreamTotal, briefStartedAtEpochMs: state.briefStartedAtEpochMs, nowEpochMs: tickNow, pulsePhase: pulsePhase })] })] }));
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;