nexrall-code 0.5.116 → 0.5.117

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,1097 +0,0 @@
1
- import React, { useState, useCallback, useRef } from 'react';
2
- import { render, Box, Text, Static, useInput, useApp, useCursor, useBoxMetrics, } from 'ink';
3
- import { colors, visibleLen } from './theme';
4
- import { createResizeDebouncer } from './resizeRepaint';
5
- import { fillerRowCount, rowsOccupied } from './screen';
6
- // Exported so padToBottom (ui/screen.ts) can measure how many rows the footer
7
- // really occupies at the current terminal width instead of assuming one — it
8
- // wraps below ~56 columns, and a wrong count scrolls the banner off-screen.
9
- export function footerText(info) {
10
- const modeLabel = info.autoApprove
11
- ? 'yolo mode on'
12
- : info.mode === 'plan' ? 'plan mode on'
13
- : info.mode === 'edit' ? 'edit mode on'
14
- : info.mode === 'ask' ? 'ask mode on'
15
- : 'auto mode on';
16
- // `Ctrl+J for newline` is spelled out here, not left to /help, because it is
17
- // the ONLY newline key that works with no terminal configuration at all —
18
- // and on some terminals it is the only one available, period. Apple Terminal
19
- // below macOS 27 is the case that forced this: its keymap
20
- // (Terminal.app/Contents/Resources/keyMappings.plist) can only rebind
21
- // codepoints in the U+F700–F8FF function-key range, and Return is U+000D, so
22
- // Shift+Return is not bindable there by any means — `claude` reaches the same
23
- // conclusion and falls back to Option+Enter on those versions. A user on such
24
- // a terminal who never opens /help would otherwise have no way to discover
25
- // that a multi-line prompt is possible at all.
26
- //
27
- // `/agents for agents` was dropped to make room. It is still listed in
28
- // /help, so nothing became undiscoverable — whereas Ctrl+J was documented
29
- // nowhere the user would look, which is the asymmetry that decided it.
30
- //
31
- // Length matters: this string feeds bottomChromeRows(), which padToBottom()
32
- // uses to decide how many filler rows to print. Swapping one hint for the
33
- // other leaves it at exactly 55 cells — the same width as before — so the
34
- // measured wrap behaviour is unchanged (one row down to 55 columns, two at
35
- // 40). See bottomChrome.test.ts, which pins both ends of that boundary.
36
- return `${modeLabel} · /help for shortcuts · Ctrl+J for newline`;
37
- }
38
- /**
39
- * Content string that renders as exactly `rows` blank Static rows.
40
- *
41
- * Not simply `'\n'.repeat(rows - 1)` for every case — verified against a
42
- * standalone Ink probe (Static items bracketed by marker rows, counting
43
- * exact printed lines) that a Static item's content string does NOT follow
44
- * the same "N newlines = N+1 rows" rule `console.log` uses. Specifically:
45
- *
46
- * • `''` (empty string) contributes ZERO rows — <Static> renders nothing
47
- * for it at all, unlike `console.log('')` which always prints a blank
48
- * line. So `rows <= 0` must special-case to `''`, and there is no
49
- * N-newlines formula that also covers `rows === 1` correctly (0
50
- * newlines there would be `''`, which is 0 rows, not 1).
51
- * • A single space (`' '`) reliably contributes exactly ONE row.
52
- * • From 2 rows up, `'\n'.repeat(rows - 1)` behaves exactly like the
53
- * console.log case and contributes exactly `rows` rows — confirmed for
54
- * 2, 3, 5, and 8.
55
- */
56
- export function fillerContent(rows) {
57
- if (rows <= 0)
58
- return '';
59
- if (rows === 1)
60
- return ' ';
61
- return '\n'.repeat(rows - 1);
62
- }
63
- const IntlWithSegmenter = Intl;
64
- const graphemeSegmenter = typeof IntlWithSegmenter.Segmenter === 'function'
65
- ? new IntlWithSegmenter.Segmenter(undefined, { granularity: 'grapheme' })
66
- : null;
67
- // ─── Cursor-aware grapheme navigation ──────────────────────────────────────
68
- //
69
- // The input box used to have no concept of a cursor position at all — every
70
- // keystroke appended to the end of the line and Backspace only ever removed
71
- // the LAST character, with left/right arrow explicitly no-ops ("not wired
72
- // yet"). That is fine for pure ASCII typed in order, but it is exactly the
73
- // editing pattern Vietnamese/CJK IME users need most: type a word, notice the
74
- // wrong tone/diacritic partway through, arrow back to it, fix it in place.
75
- // Without cursor movement that correction is impossible without deleting and
76
- // retyping everything after the mistake — which reads identically to "the
77
- // input box doesn't work" even though every individual keystroke is captured
78
- // correctly.
79
- //
80
- // All positions here are grapheme-cluster boundaries (via Intl.Segmenter,
81
- // same primitive as dropLastGrapheme above), not UTF-16 code-unit offsets —
82
- // so arrowing over an accented Vietnamese character or an emoji moves the
83
- // cursor exactly one visual character, never landing mid-cluster.
84
- function graphemeBoundaries(text) {
85
- if (graphemeSegmenter) {
86
- const bounds = [0];
87
- for (const { index } of graphemeSegmenter.segment(text)) {
88
- if (index > 0)
89
- bounds.push(index);
90
- }
91
- bounds.push(text.length);
92
- return bounds;
93
- }
94
- // Fallback mirroring dropLastGrapheme's manual surrogate/combining-mark
95
- // handling: walk forward collecting boundaries the same way it walks back.
96
- const bounds = [0];
97
- let i = 0;
98
- while (i < text.length) {
99
- let next = i + 1;
100
- const code = text.charCodeAt(i);
101
- if (code >= 0xd800 && code <= 0xdbff && next < text.length) {
102
- const low = text.charCodeAt(next);
103
- if (low >= 0xdc00 && low <= 0xdfff)
104
- next++;
105
- }
106
- while (next < text.length && /[\u0300-\u036F\u1AB0-\u1AFF\u20D0-\u20F0]/.test(text[next]))
107
- next++;
108
- bounds.push(next);
109
- i = next;
110
- }
111
- return bounds;
112
- }
113
- /** The grapheme boundary immediately before `pos` (for leftArrow/backspace-at-cursor). */
114
- export function prevGraphemeBoundary(text, pos) {
115
- const bounds = graphemeBoundaries(text);
116
- let prev = 0;
117
- for (const b of bounds) {
118
- if (b >= pos)
119
- break;
120
- prev = b;
121
- }
122
- return prev;
123
- }
124
- /** The grapheme boundary immediately after `pos` (for rightArrow/delete-at-cursor). */
125
- export function nextGraphemeBoundary(text, pos) {
126
- const bounds = graphemeBoundaries(text);
127
- for (const b of bounds) {
128
- if (b > pos)
129
- return b;
130
- }
131
- return text.length;
132
- }
133
- /**
134
- * Splits the input line into the three pieces the input box draws: the text
135
- * before the caret, the ONE character the caret sits on (rendered inverted),
136
- * and the text after it.
137
- *
138
- * This exists as a named, exported function purely so it can be tested. It
139
- * used to be three inline `line.slice(...)` expressions in the JSX below, and
140
- * the middle one was `line.slice(cursorPos, cursorPos + 1)` — a UTF-16
141
- * CODE-UNIT offset, which is exactly the mistake every helper above this
142
- * point goes to some length to avoid. All the grapheme-aware cursor
143
- * navigation was therefore thrown away at the last step, in the render:
144
- *
145
- * • "chào" typed via Telex arrives DECOMPOSED ("a" + U+0300). With the
146
- * caret on it, `+ 1` highlighted the bare "a" and left the combining
147
- * accent at the head of the trailing slice, where it reattached to
148
- * nothing — the character visibly came apart under the cursor.
149
- * • For any non-BMP character (emoji, and every ZWJ sequence or flag)
150
- * `+ 1` cut a SURROGATE PAIR in half, so the inverted cell held a lone
151
- * unpaired surrogate — the classic � — and the tail began with the other
152
- * half. Measured before this fix: 1 of 4 caret positions in decomposed
153
- * "chào" rendered a broken glyph, and 1 of 1 in "👨‍👩‍👧" / "🇻🇳".
154
- *
155
- * Advancing to the next grapheme BOUNDARY instead keeps the highlighted cell
156
- * one whole user-perceived character, which is also the unit the caret
157
- * already moves by, so the two can no longer disagree.
158
- *
159
- * `atCursor` is a single space when the caret sits at end-of-line, matching
160
- * the block-caret look the input box has always had there.
161
- */
162
- export function renderInputSlices(line, cursorPos) {
163
- const end = nextGraphemeBoundary(line, cursorPos);
164
- return {
165
- before: line.slice(0, cursorPos),
166
- atCursor: line.slice(cursorPos, end) || ' ',
167
- after: line.slice(end),
168
- };
169
- }
170
- export function caretPosition(opts) {
171
- // A zero/negative width would make the wrap arithmetic divide by zero. Ink
172
- // itself falls back to 80 when the terminal reports nothing usable; clamping
173
- // to at least 1 here keeps this pure function total for any input.
174
- const columns = Math.max(1, opts.columns);
175
- const logicalLines = opts.line.split('\n');
176
- // Which logical line the caret sits on, and how far into it — derived from
177
- // the text BEFORE the caret so it cannot disagree with renderInputSlices,
178
- // which slices the drawn text at the very same offset.
179
- const beforeLines = opts.line.slice(0, opts.cursorPos).split('\n');
180
- const caretLineIndex = beforeLines.length - 1;
181
- const widthBeforeCaret = (caretLineIndex === 0 ? opts.promptWidth : 0) +
182
- visibleLen(beforeLines[caretLineIndex] ?? '');
183
- // Rows consumed by the logical lines ABOVE the caret's own, each wrapped at
184
- // the full terminal width (and each occupying at least one row, since an
185
- // empty line still takes a row).
186
- let rowsAboveCaretLine = 0;
187
- for (let i = 0; i < caretLineIndex; i++) {
188
- const width = (i === 0 ? opts.promptWidth : 0) + visibleLen(logicalLines[i] ?? '');
189
- rowsAboveCaretLine += Math.max(1, Math.ceil(width / columns));
190
- }
191
- return {
192
- x: widthBeforeCaret % columns,
193
- // Where the input box begins (measured), plus however far down its own
194
- // wrapped/multi-line content the caret has reached.
195
- y: opts.inputRowTop + rowsAboveCaretLine + Math.floor(widthBeforeCaret / columns),
196
- };
197
- }
198
- /**
199
- * Decides whether a keypress means "submit the line", "insert a newline", or
200
- * "this is just text". Pure and exported so the table above is testable — the
201
- * terminal-dependent half of this is impossible to cover otherwise.
202
- *
203
- * `char`/`key` are exactly what Ink's `useInput` passes through.
204
- */
205
- export function decideEnterKey(char, key) {
206
- // A chunk that is EXACTLY one linefeed, with no modifier, is Ctrl+J: the
207
- // universal, zero-config "insert a newline" key. Requiring the whole chunk
208
- // to equal '\n' (rather than merely to contain one) is what keeps this from
209
- // swallowing a multi-line paste — a paste arrives as one chunk with text
210
- // around its newlines, and must still submit its first line.
211
- if (char === '\n' && !key.meta && !key.return)
212
- return { kind: 'newline' };
213
- // Option+Enter, and the sequence /terminal-setup binds to Shift+Enter in
214
- // VS Code. Both arrive as ESC-prefixed CR, which Ink reports as
215
- // return+meta. `key.shift` is accepted too so that a terminal which DOES
216
- // negotiate the Kitty protocol (or a future one sending CSI-u natively)
217
- // keeps working — it costs nothing and is the semantically intended key.
218
- if ((key.return || char === '\r' || char === '\n') && (key.meta || key.shift)) {
219
- return { kind: 'newline' };
220
- }
221
- // Scan for an embedded newline ANYWHERE in the chunk, not just at its
222
- // start. Ink's docs: "if the user pastes text and it's more than one
223
- // character, the callback will be called only once, and the whole string
224
- // will be passed as input" — which applies to any multi-byte read, not only
225
- // bracketed pastes. Typing fast enough (or a harness writing "h\r" in one
226
- // syscall) delivers char as e.g. "h\r" in a SINGLE callback with every
227
- // key.* flag false, so matching only `key.return`/`char === '\r'` would
228
- // miss the most common fast-typing submit.
229
- const newlineIdx = char.search(/[\r\n]/);
230
- if (key.return || newlineIdx !== -1)
231
- return { kind: 'submit', splitAt: newlineIdx };
232
- return { kind: 'text' };
233
- }
234
- // ─── Ctrl+C: confirm before quitting ───────────────────────────────────────
235
- //
236
- // Pressing Ctrl+C once while idle used to exit the session instantly — no
237
- // confirmation, no message. Verified in a pty before this change: a single
238
- // \x03 at the `> ` prompt ended the process immediately
239
- // (@@@PROCESS_EXITED_ON_FIRST_CTRL_C). That is a bad default for a REPL that
240
- // holds conversation state, and it is a very easy key to hit by reflex when
241
- // the intent was "stop what you're doing" or "clear this line" — the
242
- // muscle-memory meaning of Ctrl+C in most shells.
243
- //
244
- // Now a first press ARMS a quit and shows a hint; a second press within the
245
- // window actually exits. Anything else the user does (typing, Enter,
246
- // Backspace, arrows) disarms it, so a stray Ctrl+C followed by real work can
247
- // never quit the session later.
248
- //
249
- // The window matters: without a timeout, a Ctrl+C pressed now and another
250
- // pressed ten minutes later would still quit, which is the same surprise in
251
- // slow motion. 3s is long enough for a deliberate double-tap and short enough
252
- // that the armed state never outlives the user's awareness of it.
253
- export const CTRL_C_QUIT_WINDOW_MS = 3_000;
254
- export const CTRL_C_HINT = 'Press Ctrl+C again to exit';
255
- /**
256
- * Decides what a Ctrl+C press means, given what else is going on. Pure and
257
- * exported so the precedence rules are testable — this used to be an
258
- * if/else chain inline in `useInput` with no coverage at all.
259
- *
260
- * Precedence is deliberate and matches the Enter handler's own ordering:
261
- *
262
- * 1. An outstanding askLine() question (a permission y/n, /init overwrite…)
263
- * wins even while busy — Ctrl+C there means "no", which is the safe
264
- * answer, and must not be reinterpreted as a session quit.
265
- * 2. A running agent turn means "interrupt the turn", never "exit" — losing
266
- * a long run to a misplaced keystroke is exactly what this ordering
267
- * prevents. Note this needs NO confirmation: interrupting is cheap and
268
- * recoverable, so requiring a double-tap there would just make stopping
269
- * a runaway turn harder.
270
- * 3. Otherwise (idle at the prompt) the first press arms, the second quits.
271
- */
272
- export function decideCtrlC(state) {
273
- if (state.hasPendingQuestion)
274
- return 'answer-prompt';
275
- if (state.busy)
276
- return 'interrupt-turn';
277
- if (state.quitArmedAt !== null && state.now - state.quitArmedAt <= CTRL_C_QUIT_WINDOW_MS) {
278
- return 'quit';
279
- }
280
- return 'arm-quit';
281
- }
282
- let handle = null;
283
- let inkInstance = null;
284
- let idCounter = 0;
285
- let resizeDebouncer = null;
286
- let resizeListenerAttached = false;
287
- // How long to wait, after the LAST 'resize' event, before treating a drag as
288
- // finished. Long enough that a continuous drag (which fires events every few
289
- // milliseconds — verified: a 100→60 column drag fired multiple events well
290
- // under 50ms apart) never fires the repaint mid-drag; short enough that
291
- // letting go of the terminal edge feels instant, not delayed.
292
- const RESIZE_SETTLE_MS = 120;
293
- /**
294
- * Cleans up stray leftover rows below the bottom chrome once a resize burst
295
- * has settled — see resizeRepaint.ts for the full mechanism, summarized here:
296
- *
297
- * `log-update.js`'s repaint on every 'resize' event is
298
- * `eraseLines(previousLineCount) + newFrame`, where `previousLineCount`
299
- * comes from counting literal '\n's in the PREVIOUS frame it wrote — not
300
- * the terminal rows that frame actually occupied once wrapped at the width
301
- * the terminal was reporting at that instant. During a drag, each
302
- * intermediate event's frame is computed and erased against a width that
303
- * has already moved on by the next event, so `eraseLines` can clear FEWER
304
- * rows than the previous frame actually used. Those un-erased rows aren't
305
- * inside the region the next frame overwrites (that region starts at the
306
- * cursor position `eraseLines` leaves behind, which sits at the TOP of
307
- * what it erased) — they end up sitting just below the newly written
308
- * frame instead, which is exactly the reported symptom: blank/torn rows
309
- * appearing under the input box, accumulating with repeated drags.
310
- *
311
- * This used to erase from the cursor to the bottom of the screen
312
- * (`\x1b[0J`, "erase in display, cursor to end") right here, before the
313
- * full redraw below. That write is now GONE, for two independent reasons —
314
- * it became both unnecessary and actively destructive:
315
- *
316
- * 1. Unnecessary: when this function was first written (commit 5c83c19) the
317
- * `\x1b[0J` was the ONLY cleanup it did. A later commit (190a5fe) added
318
- * the forceFullRedraw() call below, which begins by writing
319
- * `\x1b[2J\x1b[H` — a clear of the ENTIRE visible screen, which
320
- * subsumes "from the cursor down" completely. Verified by replaying both
321
- * byte streams through a real terminal emulator (pyte): a frame plus a
322
- * stray leftover row, cleaned with `[2J` alone versus `[0J` then `[2J`,
323
- * produce the identical final screen.
324
- * 2. Destructive: `\x1b[0J` erases from wherever the cursor IS, and the
325
- * cursor is no longer parked after the frame's last row. It now sits ON
326
- * the caret inside the input box (see caretPosition / useCursor above,
327
- * which exists so IME composition renders in the right place), i.e. in
328
- * the MIDDLE of the chrome. Replayed through pyte with the cursor parked
329
- * at the caret, `[0J` wiped the rest of the input row, the rule below it
330
- * and the footer — leaving a truncated UI after every single resize.
331
- * The `[2J` full clear is immune to this because it does not depend on
332
- * the cursor position at all.
333
- *
334
- * Note the surviving `\x1b[2J` deliberately still is NOT
335
- * `ansiEscapes.clearTerminal` (`\x1b[2J\x1b[3J`) — `\x1b[3J` wipes the
336
- * terminal's OWN scrollback (see vadimdemedes/ink#935, which documents
337
- * exactly this complaint against Claude Code and Codex CLI), destroying
338
- * every earlier turn's transcript. Clearing only the visible screen and
339
- * having `<Static>` reprint the transcript is what keeps scrollback intact.
340
- *
341
- * Waiting for `waitUntilRenderFlush()` first (same primitive `flushInkFrame`
342
- * below uses) still matters: Ink's own `resized()` handler (ink.js) queues a
343
- * render in response to the same burst of events, and clearing before that
344
- * pending render commits would race it — the clear could land while Ink is
345
- * mid-write and corrupt that frame. Flushing first guarantees this runs
346
- * strictly after Ink's own last resize-triggered render for the settle window.
347
- */
348
- function repaintAfterResizeSettle() {
349
- const instance = inkInstance;
350
- if (!instance || !process.stdout.isTTY)
351
- return;
352
- void instance.waitUntilRenderFlush().then(() => {
353
- // The instance may have been unmounted while the flush was pending
354
- // (session ending mid-resize) — re-check before writing to a stream
355
- // whose renderer has already torn down.
356
- if (inkInstance !== instance)
357
- return;
358
- // Full-transcript reflow (and, as of the doc above, the ONLY cleanup this
359
- // path does): fixes the banner/transcript ABOVE the live chrome, which Ink
360
- // never touches on resize at all (its resize handler only re-lays-out the
361
- // currently-mounted component tree — the already-scrolled `<Static>`
362
- // output is gone from that tree the instant it's written, by design), and
363
- // its `\x1b[2J` also clears any stray rows Ink's own erase-math race left
364
- // behind during the drag.
365
- forceFullRedrawRef?.();
366
- });
367
- }
368
- // Set by the App component once mounted (see its own effect below) — module
369
- // scope is required because `attachResizeCleanup`/`repaintAfterResizeSettle`
370
- // run OUTSIDE any component, same reason `inkInstance`/`handle` are already
371
- // module-level here.
372
- let forceFullRedrawRef = null;
373
- const DEFAULT_PROMPT = colors.primary.bold('> ');
374
- const App = ({ onReady }) => {
375
- const [items, setItems] = useState([]);
376
- // Bumped on every resize-settle to force React to treat <Static> as a BRAND
377
- // NEW instance (see the `key` prop below) instead of the same one with an
378
- // unchanged items array. <Static> only ever emits items it has not already
379
- // committed — passing the identical array back a second time is a no-op as
380
- // far as it's concerned, so there is no other way to make it re-print
381
- // everything already on screen. See forceFullRedraw's own comment for why
382
- // this whole mechanism exists at all.
383
- const [staticKey, setStaticKey] = useState(0);
384
- const [footer, setFooterState] = useState({ mode: 'auto', autoApprove: false });
385
- // Whether an agent turn is currently running. This used to be called
386
- // `inputEnabled` and, when false, made `useInput` bail out on EVERY key —
387
- // typing, cursor movement, Ctrl+C, and even the permission y/n prompt asked
388
- // mid-turn via askLine(). That combination meant a running turn could never
389
- // be interrupted from the keyboard at all (raw mode disables the kernel's
390
- // own Ctrl+C→SIGINT translation, so Ink's own handling was the only path
391
- // left), and a permission question fired during that same turn hung
392
- // forever waiting for a keypress the input box had stopped accepting.
393
- // `busy` still exists to change ONE thing — what a submitted line without
394
- // an outstanding askLine() question does (queue vs. dispatch immediately,
395
- // see the Enter branch in useInput below) — but it never gates whether a
396
- // key is processed at all.
397
- const [busy, setBusyState] = useState(false);
398
- const [line, setLineState] = useState('');
399
- // 0-based grapheme-cluster index of the caret within `line`. Left/right
400
- // arrow move this without touching the text; typing/backspace/delete act
401
- // AT this position instead of always at the end.
402
- const [cursorPos, setCursorPosState] = useState(0);
403
- const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
404
- const { exit } = useApp();
405
- // ─── Why the typed line lives in a ref, not just React state ─────────────
406
- //
407
- // `useInput`'s callback closes over the `line` value from the render it was
408
- // created in. React state updates are asynchronous, so within a SINGLE
409
- // keypress callback a `setLine(...)` is NOT visible to a subsequent read of
410
- // `line` in that same callback — it still holds the previous render's value.
411
- //
412
- // That is a real, reproducible data-loss bug on the submit path, not a
413
- // theoretical one: pressing Enter has to first commit any
414
- // just-typed-but-not-yet-committed text and then read the full line to
415
- // submit it. Reading the stale `line` there meant the text typed since the
416
- // last render was silently dropped — confirmed in a pty test where typing
417
- // "chào" and pressing Enter within ~50ms submitted an EMPTY string.
418
- //
419
- // `lineRef` is the authoritative, always-current value, updated
420
- // synchronously; `line` state exists purely to trigger re-renders. Every
421
- // mutation goes through `setLine()` below so the two can never drift.
422
- // `cursorRef` mirrors it for the same reason, now that caret position is
423
- // also read-then-written within a single keypress callback.
424
- const lineRef = useRef('');
425
- const setLine = useCallback((next) => {
426
- const value = typeof next === 'function' ? next(lineRef.current) : next;
427
- lineRef.current = value;
428
- setLineState(value);
429
- }, []);
430
- const cursorRef = useRef(0);
431
- const setCursorPos = useCallback((next) => {
432
- const value = typeof next === 'function' ? next(cursorRef.current) : next;
433
- cursorRef.current = value;
434
- setCursorPosState(value);
435
- }, []);
436
- // One-off question resolver (permission y/n, /init overwrite confirm…).
437
- // Takes priority over the persistent onLine/onQueuedLine subscriptions for
438
- // one submit — checked FIRST in the Enter branch below regardless of
439
- // `busy`, which is what lets a permission prompt fired mid-turn actually
440
- // be answered.
441
- const askResolverRef = useRef(null);
442
- // Persistent handler for ordinary REPL messages (chat.ts's rl.on('line')).
443
- const onLineRef = useRef(null);
444
- // Persistent handler for a line submitted WHILE busy (no askLine pending) —
445
- // a follow-up typed during a running turn, folded in as a queued message.
446
- const onQueuedLineRef = useRef(null);
447
- // Always-current mirror of `busy` for the same stale-closure reason as
448
- // lineRef/cursorRef — useEffectEvent gives useInput a fresh closure per
449
- // render regardless, but reading a ref keeps this branch consistent with
450
- // the rest of the submit path and avoids relying on that Ink internal.
451
- const busyRef = useRef(false);
452
- // Timestamp of a Ctrl+C that armed a quit, or null when nothing is armed.
453
- // Mirrored into a ref for the same stale-closure reason as lineRef: the
454
- // Ctrl+C branch READS the armed state and WRITES it within a single
455
- // keypress callback, so reading React state there would see the previous
456
- // render's value and the second press would arm again instead of quitting.
457
- const [quitArmed, setQuitArmedState] = useState(null);
458
- const quitArmedAtRef = useRef(null);
459
- const setQuitArmed = useCallback((at) => {
460
- quitArmedAtRef.current = at;
461
- setQuitArmedState(at);
462
- }, []);
463
- const [live, setLiveState] = useState('');
464
- const print = useCallback((text, opts) => {
465
- setItems((prev) => [...prev, { id: idCounter++, content: text, isBanner: opts?.isBanner }]);
466
- }, []);
467
- // See AppHandle.setBannerRegenerator's own doc for what this is for. A ref
468
- // (not state) because it's only ever READ from the resize-settle path below,
469
- // never rendered — there is nothing here for React to re-render over.
470
- const bannerRegeneratorRef = useRef(null);
471
- const setBannerRegenerator = useCallback((fn) => {
472
- bannerRegeneratorRef.current = fn;
473
- }, []);
474
- // Mirrors `footer` for the same stale-closure reason as lineRef/busyRef:
475
- // repadBottomChrome (below) is a useCallback with an empty dep array, so it
476
- // must read the CURRENT footer through a ref rather than closing over
477
- // whatever `footer` was at the render it was created in.
478
- const footerRef = useRef({ mode: 'auto', autoApprove: false });
479
- const setFooter = useCallback((info) => {
480
- footerRef.current = info;
481
- setFooterState(info);
482
- }, []);
483
- const setLive = useCallback((text) => setLiveState(text), []);
484
- const setBusy = useCallback((value) => {
485
- busyRef.current = value;
486
- setBusyState(value);
487
- }, []);
488
- const askLine = useCallback((promptText) => {
489
- setPrompt(promptText || DEFAULT_PROMPT);
490
- setLine('');
491
- setCursorPos(0);
492
- return new Promise((resolve) => {
493
- askResolverRef.current = (answer) => {
494
- setPrompt(DEFAULT_PROMPT);
495
- resolve(answer);
496
- };
497
- });
498
- }, []);
499
- const onLine = useCallback((cb) => {
500
- onLineRef.current = cb;
501
- }, []);
502
- const onQueuedLine = useCallback((cb) => {
503
- onQueuedLineRef.current = cb;
504
- }, []);
505
- // Fired when Ctrl+C is pressed WHILE an agent turn is running (busy===true
506
- // and no askLine() question is outstanding). chat.ts subscribes to this to
507
- // set its abortSignal, mirroring the VS Code panel's stopGeneration —
508
- // interrupt the turn, don't kill the whole session. Left null when idle:
509
- // in that case Ctrl+C falls through to Ink's own exit()/process teardown,
510
- // unchanged from before.
511
- const onInterruptRef = useRef(null);
512
- const onInterrupt = useCallback((cb) => {
513
- onInterruptRef.current = cb;
514
- }, []);
515
- // Notified when the session is being torn down deliberately, so chat.ts can
516
- // save the conversation first — see AppHandle.onExit.
517
- const onExitRef = useRef(null);
518
- const onExit = useCallback((cb) => {
519
- onExitRef.current = cb;
520
- }, []);
521
- // Both the confirmed-Ctrl+C path and `close()` route through here so the
522
- // save can never be attached to only one of them. Guarded against running
523
- // twice: chat.ts's 'close' handler calls process.exit(), but a double fire
524
- // would otherwise save the same session twice on the way out.
525
- //
526
- // `notify` distinguishes WHO initiated the teardown, and it matters:
527
- //
528
- // • true — the USER quit from inside the UI (confirmed Ctrl+C). chat.ts
529
- // doesn't know yet, so it must be told in order to save the session.
530
- // • false — chat.ts called `close()` ITSELF (`/exit`, `/update`). It has
531
- // already done whatever saving it wants and is mid-shutdown, so firing
532
- // onExit would re-enter its own 'close' handler. For `/exit` that meant
533
- // a duplicate save and a second "Goodbye."; for `/update` it was worse —
534
- // that handler calls process.exit(0), which would have killed the
535
- // process BEFORE `await updateCommand()` ever ran, silently breaking
536
- // self-update.
537
- const exitedRef = useRef(false);
538
- const requestExit = useCallback((notify) => {
539
- if (exitedRef.current)
540
- return;
541
- exitedRef.current = true;
542
- if (notify)
543
- onExitRef.current?.();
544
- exit();
545
- // eslint-disable-next-line react-hooks/exhaustive-deps
546
- }, []);
547
- // ─── IME (Vietnamese Telex/VNI, CJK) input ───────────────────────────────
548
- //
549
- // An earlier revision buffered "IME-like" (non-ASCII) input here for 50ms
550
- // before committing it, mirroring the shape of an unmerged upstream Ink PR
551
- // (vadimdemedes/ink#865) that was written against the widely-reported IME
552
- // character-loss bug (vadimdemedes/ink#759, anthropics/claude-code#22853).
553
- // That buffering is deliberately GONE, for two reasons:
554
- //
555
- // 1. It is no longer necessary. Those upstream reports predate Ink's
556
- // current input pipeline. Ink 7's `input-parser.js` splits backspace
557
- // bytes — BOTH 0x7F (DEL, macOS/Linux IMEs) and 0x08 (BS, Windows IMEs,
558
- // cf. anthropics/claude-code#33891) — into their own key events, and
559
- // delivers the accented text that follows as an intact chunk. Verified
560
- // directly against ink@7.1.1: the Telex backspace-then-recompose
561
- // sequence "\x7Fá" arrives as [backspace, "á"], which the plain handler
562
- // below already resolves correctly (delete "a", insert "á").
563
- //
564
- // 2. It actively caused the very data loss it was meant to prevent. Holding
565
- // text in a side buffer for 50ms means Enter can arrive while text is
566
- // still uncommitted, which combined with the stale-closure problem
567
- // described at `lineRef` above submitted an EMPTY line — reproduced in a
568
- // pty test by typing "chào" and pressing Enter within 50ms. It also added
569
- // a visible 50ms lag to every accented character.
570
- //
571
- // So: no buffering, no timers, no non-ASCII special-casing. Every keystroke
572
- // is committed synchronously to `lineRef`, exactly like ASCII input.
573
- //
574
- // NOTE ON `busy`: unlike the removed `inputEnabled` gate, `busy` is never
575
- // checked at the TOP of this callback — every branch below (Ctrl+C,
576
- // backspace, cursor movement, typing) runs identically whether or not an
577
- // agent turn is in flight. `busy` only changes what a completed submit
578
- // (the Enter branch) does with the finished line, and only when no
579
- // askLine() question is currently outstanding.
580
- useInput((char, key) => {
581
- if (key.ctrl && char === 'c') {
582
- // Precedence (answer a prompt > interrupt a turn > arm/confirm quit)
583
- // lives in decideCtrlC so it can be tested — see that function.
584
- const action = decideCtrlC({
585
- hasPendingQuestion: askResolverRef.current !== null,
586
- busy: busyRef.current && onInterruptRef.current !== null,
587
- quitArmedAt: quitArmedAtRef.current,
588
- now: Date.now(),
589
- });
590
- if (action === 'answer-prompt') {
591
- const askResolve = askResolverRef.current;
592
- askResolverRef.current = null;
593
- setLine('');
594
- setCursorPos(0);
595
- askResolve('n');
596
- return;
597
- }
598
- if (action === 'interrupt-turn') {
599
- // Interrupting is cheap and recoverable, so it stays a SINGLE press —
600
- // and it clears any armed quit, so "stop this turn" can never be the
601
- // first half of an accidental exit.
602
- setQuitArmed(null);
603
- onInterruptRef.current?.();
604
- return;
605
- }
606
- if (action === 'arm-quit') {
607
- setQuitArmed(Date.now());
608
- return;
609
- }
610
- // Confirmed quit. The user initiated this from inside the UI, so
611
- // notify chat.ts (notify=true) to persist the conversation first.
612
- requestExit(true);
613
- return;
614
- }
615
- // ANY other key disarms a pending quit: an accidental Ctrl+C followed by
616
- // real work must not leave the session one keystroke from exiting.
617
- if (quitArmedAtRef.current !== null)
618
- setQuitArmed(null);
619
- if (key.backspace || key.delete) {
620
- // Backspace acts on the char BEFORE the cursor, Delete on the char
621
- // AFTER it — both grapheme-cluster-aware. Previously this always
622
- // dropped the LAST character in the line regardless of cursor
623
- // position because there was no cursor position at all; the line
624
- // itself is unaffected here whenever the cursor already sits at the
625
- // relevant edge (start for backspace, end for delete).
626
- if (key.delete) {
627
- setLine((s) => {
628
- const pos = cursorRef.current;
629
- const end = nextGraphemeBoundary(s, pos);
630
- return s.slice(0, pos) + s.slice(end);
631
- });
632
- return;
633
- }
634
- setLine((s) => {
635
- const pos = cursorRef.current;
636
- if (pos === 0)
637
- return s;
638
- const start = prevGraphemeBoundary(s, pos);
639
- setCursorPos(start);
640
- return s.slice(0, start) + s.slice(pos);
641
- });
642
- return;
643
- }
644
- if (key.leftArrow) {
645
- setCursorPos((pos) => prevGraphemeBoundary(lineRef.current, pos));
646
- return;
647
- }
648
- if (key.rightArrow) {
649
- setCursorPos((pos) => nextGraphemeBoundary(lineRef.current, pos));
650
- return;
651
- }
652
- if (key.upArrow || key.downArrow || key.tab) {
653
- return; // history not wired yet — out of scope for this pass
654
- }
655
- // What Enter, Ctrl+J, Option+Enter and Shift+Enter each mean is decided
656
- // by decideEnterKey — see its own doc comment for the measured
657
- // byte/`key.*` table behind those rules, and for why `key.shift` alone
658
- // could never work.
659
- const enterAction = decideEnterKey(char, key);
660
- if (enterAction.kind === 'newline') {
661
- const pos = cursorRef.current;
662
- setLine((s) => s.slice(0, pos) + '\n' + s.slice(pos));
663
- setCursorPos(pos + 1);
664
- return;
665
- }
666
- if (enterAction.kind === 'submit') {
667
- const newlineIdx = enterAction.splitAt;
668
- const before = newlineIdx === -1 ? char : char.slice(0, newlineIdx);
669
- const after = newlineIdx === -1 ? '' : char.slice(newlineIdx + 1).replace(/^[\r\n]/, '');
670
- // Read the line from `lineRef`, NOT from the `line` state variable —
671
- // see the comment at `lineRef` above. The state variable holds whatever
672
- // the last render saw, which omits anything typed since; using it here
673
- // is what silently submitted empty/truncated lines. Insert `before` AT
674
- // the cursor (not always appended) so text typed after arrowing back
675
- // into the middle of the line submits in the right order.
676
- const pos = cursorRef.current;
677
- const submitted = lineRef.current.slice(0, pos) + before + lineRef.current.slice(pos);
678
- setLine(after);
679
- setCursorPos(0);
680
- // Echo the submitted line into the permanent transcript ourselves —
681
- // Ink's input box doesn't leave a trace once cleared/replaced.
682
- print(prompt + submitted);
683
- const askResolve = askResolverRef.current;
684
- if (askResolve) {
685
- askResolverRef.current = null;
686
- askResolve(submitted);
687
- }
688
- else if (busyRef.current) {
689
- // A follow-up sent while the agent is still working — fold it into
690
- // the running turn instead of trying to start a second one. Matches
691
- // the VS Code panel's queueMessage behavior.
692
- onQueuedLineRef.current?.(submitted);
693
- }
694
- else {
695
- onLineRef.current?.(submitted);
696
- }
697
- return;
698
- }
699
- setLine((s) => {
700
- const pos = cursorRef.current;
701
- setCursorPos(pos + char.length);
702
- return s.slice(0, pos) + char + s.slice(pos);
703
- });
704
- });
705
- // ── Full-transcript reflow on resize-settle ───────────────────────────────
706
- //
707
- // Mirrors Claude Code's own resize handling exactly (verified directly
708
- // against a real `claude` binary in a pty harness: a plain 100→50 column
709
- // resize, both with an empty session and with a completed conversation
710
- // turn on screen, writes `\x1b[2J\x1b[H` then reprints EVERY line —
711
- // banner, transcript, input, footer — recomputed for the new width).
712
- //
713
- // This exists because Ink's own resize handling (ink.js `resized()`) only
714
- // re-lays-out the CURRENTLY MOUNTED component tree — the live chrome
715
- // (separator/input/footer) below. Anything already flushed through
716
- // `<Static>` (the banner, every printed transcript line) is gone from that
717
- // tree the instant it's written, by design — `<Static>` exists specifically
718
- // so Ink never has to re-render old output. Ink is therefore structurally
719
- // incapable of reflowing it, and the terminal is left to auto-wrap
720
- // whatever bytes are already sitting in its buffer at the OLD width. For a
721
- // plain transcript line that is usually harmless (soft-wrapped prose reflows
722
- // fine either way), but for the banner's fixed-width box-drawing frame it
723
- // visibly tears — the `╭─...─╮` border was built for the OLD column count
724
- // and does not divide evenly into the new one.
725
- //
726
- // The fix: raw-clear the screen and force <Static> to re-emit every item it
727
- // already committed, by giving it a NEW `key` (React then mounts a brand
728
- // new instance, which has committed nothing yet — see the `key={staticKey}`
729
- // prop below). Verified in an isolated Ink probe before wiring this in: a
730
- // resize event, `\x1b[2J\x1b[H`, then bumping the key reprinted every
731
- // existing item as fresh output, wrapped at the CURRENT terminal width
732
- // (border rule length matched the new column count exactly, not the one it
733
- // was originally drawn at).
734
- //
735
- // The banner specifically needs MORE than a reflow, which is why it also
736
- // gets regenerated from source (bannerRegeneratorRef) rather than just
737
- // replayed verbatim: its box-drawing frame is fixed-width ASCII art
738
- // (`'─'.repeat(N)`), not prose. Auto-wrap alone would break the frame's
739
- // corners across two lines instead of producing a correctly-proportioned
740
- // SMALLER box, which is exactly the tearing this whole feature exists to
741
- // fix. Recomputing it at the new width and swapping it in (by id, so this
742
- // never depends on the banner always being items[0]) is what makes it look
743
- // identical to Claude Code's own resize behavior instead of merely
744
- // "not obviously broken".
745
- //
746
- // Deliberately does NOT touch scrollback (no `\x1b[3J`) — see the long
747
- // comment on repaintAfterResizeSettle's own `\x1b[0J` for why that matters;
748
- // the same reasoning applies here. `\x1b[2J` only clears the CURRENTLY
749
- // VISIBLE screen, which Claude Code's own behavior confirms is an accepted,
750
- // deliberate trade-off for this feature (losing what's on the visible
751
- // screen at the moment of resize, in exchange for the banner/transcript
752
- // always matching the current terminal width) — not a scrollback-destroying
753
- // regression like the `\x1b[3J` case that comment warns against.
754
- // ── Keep the footer pinned to the terminal's LAST row across a raw-clear ──
755
- //
756
- // Both forceFullRedraw (resize) and collapseStartupPadding (first message)
757
- // do the SAME `\x1b[2J\x1b[H` + <Static> remount, and that remount replays
758
- // ONLY what is in `items` — so any blank filler rows padToBottom() (screen.ts)
759
- // printed via plain `console.log` at startup are NOT in `items` and are
760
- // permanently wiped the first time either path runs. Verified by pty capture
761
- // (headless terminal emulation, not a screenshot guess): before this fix,
762
- // the footer sat at the true last row right after boot, then jumped up to
763
- // mid-screen — leaving a dozen-plus blank rows below it — the instant
764
- // EITHER the first message was sent OR the terminal was resized, and never
765
- // returned to the bottom for the rest of the session. Both call sites share
766
- // this exact defect because both share this exact redraw mechanism, so the
767
- // fix lives in one place and both call it.
768
- //
769
- // The fix: recompute the SAME filler amount padToBottom would have printed
770
- // (fillerRowCount — same formula, factored out so the two can't drift
771
- // apart) against the terminal's CURRENT dimensions and the items that will
772
- // actually survive the remount, then push that filler as a FILLER ITEM
773
- // into `items` itself — so it rides along with every future remount instead
774
- // of being plain stdout output that only the CURRENT remount replays.
775
- //
776
- // Only counts rows that will actually be replayed: this must run AFTER any
777
- // content mutation for this redraw (e.g. forceFullRedraw's banner
778
- // regeneration) has been queued, and BEFORE the remount, so the row tally
779
- // matches what <Static> is about to emit. Uses `setItems`'s updater form to
780
- // read the truly-current items array rather than a stale `items` closed
781
- // over at the time this useCallback was created (same class of bug the
782
- // busyRef/lineRef refs elsewhere in this file exist to avoid) — the updater
783
- // callback always receives React's latest committed state, ref or not.
784
- //
785
- // A previous filler item (tagged `isFiller`) is REMOVED before recomputing,
786
- // not just replaced in place: leaving a stale one and adding a second would
787
- // silently double the padding on every subsequent resize, and its row count
788
- // is stale the instant the terminal is resized again anyway.
789
- const repadBottomChrome = useCallback(() => {
790
- if (!process.stdout.isTTY)
791
- return;
792
- setItems((prev) => {
793
- const withoutOldFiller = prev.filter((it) => !it.isFiller);
794
- const printedRows = withoutOldFiller.reduce((sum, it) => sum + rowsOccupied(it.content, process.stdout.columns || 80), 0);
795
- const filler = fillerRowCount(printedRows, footerRef.current ? footerText(footerRef.current) : '');
796
- if (filler <= 0)
797
- return withoutOldFiller;
798
- return [...withoutOldFiller, { id: idCounter++, content: fillerContent(filler), isFiller: true }];
799
- });
800
- }, []);
801
- const forceFullRedraw = useCallback(() => {
802
- process.stdout.write('\x1b[2J\x1b[H');
803
- const regen = bannerRegeneratorRef.current;
804
- if (regen) {
805
- const columns = process.stdout.columns || 80;
806
- setItems((prev) => prev.map((it) => it.isBanner ? { ...it, content: regen(columns) } : it));
807
- }
808
- repadBottomChrome();
809
- setStaticKey((k) => k + 1);
810
- }, [repadBottomChrome]);
811
- React.useEffect(() => {
812
- forceFullRedrawRef = forceFullRedraw;
813
- return () => { forceFullRedrawRef = null; };
814
- }, [forceFullRedraw]);
815
- // See AppHandle.collapseStartupPadding's own doc for the full "why" — this
816
- // is the SAME raw-clear + <Static> remount forceFullRedraw uses for resize,
817
- // deliberately WITHOUT the banner regenerator step: the terminal's column
818
- // count hasn't changed here (only its scrollback contents have), so the
819
- // banner's existing box-drawing frame is already correctly sized and
820
- // reprinting it verbatim (rather than recomputing it) is both correct and
821
- // one fewer moving part. `items` itself is untouched — this only changes
822
- // WHERE `<Static>` re-emits them (the current cursor row, which sits right
823
- // after the banner once the raw clear runs) rather than WHAT it emits.
824
- //
825
- // Awaits `inkInstance?.waitUntilRenderFlush()` FIRST (2026-08-14 fix — see
826
- // AppHandle.collapseStartupPadding's own doc for the exact race this closes):
827
- // the caller (chat.ts's `rl.on('line', …)`) fires from inkTerminal's own Enter
828
- // branch immediately after that branch calls `print()` to echo the just-typed
829
- // line, a state update Ink may not have flushed to the terminal yet. Clearing
830
- // synchronously there could raw-clear + remount `<Static>` before Ink ever
831
- // committed that echoed line to a frame, losing it — exactly the same class of
832
- // bug repaintAfterResizeSettle's own doc warns `\x1b[0J` against firing before
833
- // a pending render commits. Mirrors that function's ordering exactly.
834
- const collapseStartupPadding = useCallback(async () => {
835
- await inkInstance?.waitUntilRenderFlush();
836
- // `\x1b[3J` (erase scrollback) is the fix for the "two banners after the
837
- // first message" bug — see this function's own doc on the AppHandle
838
- // interface above for the full xterm.js-verified root cause. Without it,
839
- // real terminals scroll the erased pre-clear frame into scrollback
840
- // instead of deleting it, so the <Static> remount below writes a second,
841
- // fresh copy of the banner right after the stale one.
842
- process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
843
- repadBottomChrome();
844
- setStaticKey((k) => k + 1);
845
- }, [repadBottomChrome]);
846
- // ── Route console.log/console.error through `items` instead of raw stdout ─
847
- //
848
- // Ink's own patchConsole (installed by render(), see ink.js's `patchConsole()`)
849
- // writes console.log/error output DIRECTLY to the terminal — it erases Ink's
850
- // current frame, writes the line, then repaints the frame below, but the
851
- // line ITSELF never enters `items`. That is fine for a frame Ink never has
852
- // to reproduce later, but forceFullRedraw (resize) and collapseStartupPadding
853
- // (first message) both raw-clear the screen and remount `<Static>`, and a
854
- // remount replays ONLY `items`. Every agent reply (MarkdownStreamRenderer),
855
- // every tool-use/result line (formatToolUse/formatToolResult), and every
856
- // startup diagnostic (permission rules, loaded skills, MCP connection
857
- // status) goes through plain console.log/console.error in chat.ts and
858
- // theme.ts — none of it is in `items` — so ALL of it was silently erased
859
- // the instant either raw-clear path fired, not merely scrolled off-screen.
860
- //
861
- // Verified via pty (headless terminal emulation) against a real full turn:
862
- // sending "reply with exactly ZEBRA_REPLY_ONLY" then resizing left the
863
- // echoed prompt line (which DOES go through print()) on screen, but the
864
- // agent's actual reply line was gone — 0 occurrences of the marker where
865
- // the reply used to be, not 1. Comparing against the real `claude` binary
866
- // in the identical scenario (two full turns, then a resize) confirmed
867
- // `claude` keeps BOTH replies fully intact — this is a real regression to
868
- // fix, not an accepted trade-off.
869
- //
870
- // Re-patching console.log/console.error HERE, inside this effect, runs
871
- // AFTER Ink's own patchConsole (installed synchronously by render(), before
872
- // this component's effects ever run) — so `console.log` at this point is
873
- // already Ink's wrapped version, and this replaces it with one that routes
874
- // through `print()` (→ `items` → survives every future remount) instead.
875
- // This must NOT run before startRowCount() (chat.ts) does its OWN
876
- // console.log/error patching for the startup-padding tally — the ordering
877
- // there is: startInkTerminal() (mounts this component, its effects run
878
- // synchronously as part of that commit) THEN startRowCount() in chat.ts.
879
- // So by the time startRowCount() installs ITS wrapper, this one is already
880
- // in place underneath it, and startRowCount's wrapper calls `originalLog`
881
- // (this function) after tallying — exactly the same layering Ink's own
882
- // patchConsole already established one level down. Three layers deep,
883
- // each calling into the next, each doing its own job once.
884
- React.useEffect(() => {
885
- const inkPatchedLog = console.log;
886
- const inkPatchedError = console.error;
887
- console.log = (...args) => {
888
- // console.log() with no arguments prints a single blank line — matches
889
- // the convention every call site in this codebase already relies on
890
- // (chat.ts's bare `console.log();` between sections).
891
- print(args.length === 0 ? ' ' : args.map(String).join(' '));
892
- };
893
- console.error = (...args) => {
894
- print(args.length === 0 ? ' ' : args.map(String).join(' '));
895
- };
896
- return () => {
897
- console.log = inkPatchedLog;
898
- console.error = inkPatchedError;
899
- };
900
- }, [print]);
901
- React.useEffect(() => {
902
- onReady({
903
- print, setFooter, setBannerRegenerator, setLive, askLine, onLine, onQueuedLine, onInterrupt,
904
- onExit, setBusy, close: () => requestExit(false), collapseStartupPadding,
905
- });
906
- // eslint-disable-next-line react-hooks/exhaustive-deps
907
- }, []);
908
- // The three pieces the input row draws (text before the caret, the single
909
- // grapheme under it, the text after). Computed once per render rather than
910
- // inline in the JSX so the grapheme-boundary logic lives in one tested
911
- // place — see renderInputSlices for what went wrong when this was inline.
912
- const slices = renderInputSlices(line, cursorPos);
913
- // ── Park the terminal's REAL cursor on the caret, every render ────────────
914
- //
915
- // See caretPosition's own doc for the full reasoning and the pty evidence:
916
- // in short, terminals draw IME composition (VS Code's xterm.js overlay,
917
- // Terminal.app's marked text) at the REAL cursor, and Ink hides it and never
918
- // moves it, so it sat on the footer — which is why composing Vietnamese
919
- // appeared a row BELOW the input box and only jumped up once Space committed
920
- // it. `useCursor` is Ink's own API for exactly this ("essential for IME
921
- // support, where the composing character is displayed at the cursor
922
- // location"), and it also SHOWS the cursor at that cell, matching what the
923
- // real `claude` binary does at the end of every frame.
924
- //
925
- // Called unconditionally during render, not from an effect: the hook records
926
- // the position and flushes it in a useInsertionEffect that runs as part of
927
- // the same commit Ink renders the frame from (use-cursor.js), so setting it
928
- // here is what keeps the cursor and the drawn caret in the same frame rather
929
- // than one frame apart.
930
- //
931
- // ── Why the input row's offset is MEASURED, not computed ─────────────────
932
- //
933
- // Everything above the input box (the streaming `live` region, then the rule)
934
- // has to be counted to know which frame row the caret is on. Computing that
935
- // with rowsOccupied() — the helper the padding math already uses — is WRONG
936
- // here, and not just theoretically: rowsOccupied wraps by CELL COUNT
937
- // (`ceil(width / columns)`, i.e. character wrap), while the live region is a
938
- // plain <Text> that Ink lays out with wrap-ansi's WORD wrap, which can spend
939
- // strictly more rows on the same string. Found with an Ink probe comparing
940
- // the prediction against Yoga's own computed layout at 20 columns: the live
941
- // string "aaaaaaaaaaa bbbbbbbbbbb ccccccccccc" (three 11-cell words) really
942
- // occupies 4 rows but rowsOccupied predicts 3 — a one-row error, which would
943
- // put the cursor (and therefore the IME composition overlay) one row above
944
- // the caret for exactly as long as a long status line was streaming.
945
- //
946
- // `useBoxMetrics` reads the position straight out of the Yoga layout Ink just
947
- // computed for the very frame being rendered, so it cannot disagree with what
948
- // Ink actually drew, whatever wrapping mode any sibling above uses. It
949
- // returns 0 until the first layout pass completes (`hasMeasured` false), and
950
- // hiding the cursor for that single first frame is better than showing it at
951
- // a guessed position.
952
- const inputRowRef = useRef(null);
953
- const inputMetrics = useBoxMetrics(inputRowRef);
954
- const { setCursorPosition } = useCursor();
955
- setCursorPosition(inputMetrics.hasMeasured
956
- ? caretPosition({
957
- promptWidth: visibleLen(prompt),
958
- line,
959
- cursorPos,
960
- columns: process.stdout.columns || 80,
961
- inputRowTop: inputMetrics.top,
962
- })
963
- : undefined);
964
- // ── Why the bottom chrome is NOT pinned with Ink layout ──────────────────
965
- //
966
- // The separator/input/footer render immediately after whatever has already
967
- // been printed, rather than being forced to the terminal's last row by a
968
- // full-height flex container. Two attempts at the latter (0.5.36, and again
969
- // while investigating this) both regressed startup by scrolling the banner
970
- // off-screen. Measured with three standalone Ink probes under a real pty:
971
- //
972
- // 1. height={rows} + a flexGrow spacer → footer pushed one row
973
- // OFF-SCREEN (full-height frame plus Ink's trailing newline overflows).
974
- // 2. height={rows-1} + spacer, no <Static> → footer correctly pinned.
975
- // 3. height={rows-1} + spacer WITH <Static> → footer pinned, but the first
976
- // transcript lines scrolled away permanently — both when <Static>
977
- // committed three times (as real startup does) and when it committed
978
- // once with every item present in the first frame.
979
- //
980
- // Probe 3 isolates the cause: <Static> is `position: 'absolute'` and
981
- // contributes no layout height, so the spacer always expands as though the
982
- // transcript were empty, making the frame taller than the room left beneath
983
- // the already-printed transcript.
984
- //
985
- // The bottom gap is instead closed at startup by padToBottom() (ui/screen.ts),
986
- // which prints the right number of blank rows through Ink's patched
987
- // console.log so the chrome comes to rest near the last row without any
988
- // full-height frame being involved.
989
- return (React.createElement(Box, { flexDirection: "column" },
990
- React.createElement(Static, { items: items, key: staticKey }, (item) => React.createElement(Text, { key: item.id }, item.content)),
991
- live ? React.createElement(Text, null, live) : null,
992
- React.createElement(Box, { borderStyle: "single", borderTop: false, borderBottom: true, borderLeft: false, borderRight: false, borderDimColor: true }),
993
- React.createElement(Box, { ref: inputRowRef },
994
- React.createElement(Text, { wrap: "hard" },
995
- React.createElement(Text, null, prompt),
996
- React.createElement(Text, null, slices.before),
997
- React.createElement(Text, { inverse: true }, slices.atCursor),
998
- React.createElement(Text, null, slices.after))),
999
- React.createElement(Box, { borderStyle: "single", borderTop: false, borderBottom: true, borderLeft: false, borderRight: false, borderDimColor: true }),
1000
- quitArmed !== null
1001
- ? React.createElement(Text, { color: "yellow" }, CTRL_C_HINT)
1002
- : React.createElement(Text, { dimColor: true },
1003
- footerText(footer),
1004
- busy ? ' · agent working…' : '')));
1005
- };
1006
- /** Boots the Ink app once, at interactive-session start. Idempotent no-op if already running. */
1007
- export function startInkTerminal() {
1008
- if (handle)
1009
- return Promise.resolve(handle);
1010
- return new Promise((resolve) => {
1011
- inkInstance = render(React.createElement(App, { onReady: (h) => {
1012
- handle = h;
1013
- attachResizeCleanup();
1014
- resolve(h);
1015
- } }), {
1016
- exitOnCtrlC: false,
1017
- // `kittyKeyboard` is deliberately OMITTED — no option object at all,
1018
- // matching the real `claude` binary exactly (verified: capturing
1019
- // claude's raw startup bytes in a pty shows no `\x1b[>1u` enable
1020
- // sequence and no `\x1b[?u` query anywhere in its output; it relies
1021
- // purely on ordinary UTF-8 keystrokes). Ink's own opt-in check
1022
- // (`if (!this.options.kittyKeyboard) return;` in ink.js) then does
1023
- // nothing at all: no enable sequence is ever written to the terminal.
1024
- //
1025
- // This used to be `{ mode: 'enabled', flags: [...] }`, to populate
1026
- // `key.shift` on Enter for Shift+Enter detection. That traded away
1027
- // more than the tradeoff docs here previously accounted for: the
1028
- // protocol doesn't just risk an inert unrecognised escape sequence on
1029
- // unsupporting terminals — on a REAL Mac Terminal.app with the
1030
- // system's built-in Vietnamese Telex input source active, enabling it
1031
- // broke typing accented characters entirely (reported: chào/việt
1032
- // could be typed in `claude` but not in `nex`, same machine, same
1033
- // input source, only the enable sequence differed between the two
1034
- // binaries — confirmed by pty-capturing both). The Kitty protocol
1035
- // shifts key delivery from "already-composed UTF-8 text" to
1036
- // raw keycodes, which is exactly the layer an IME's composition
1037
- // depends on; some terminals that don't fully implement the protocol
1038
- // still react to the enable sequence by changing how they hand off
1039
- // composed IME text, rather than silently ignoring it as intended.
1040
- //
1041
- // Shift+Enter therefore no longer gets a distinguishable `key.shift`
1042
- // on any terminal — it always submits, same as plain Enter, exactly
1043
- // matching claude's own behavior. The existing "\" continuation for
1044
- // multi-line input (chat.ts) remains the way to enter a literal
1045
- // newline, unchanged.
1046
- });
1047
- });
1048
- }
1049
- /**
1050
- * Attaches the resize-settle cleanup (see repaintAfterResizeSettle above).
1051
- * Deliberately separate from Ink's OWN `stdout.on('resize', …)` listener —
1052
- * this one only debounces and, once quiet, erases stray leftover rows; it
1053
- * never touches layout or triggers a React render itself, so it cannot
1054
- * introduce a second source of "what should this frame look like" for
1055
- * Ink's own resize handling to race against (the exact failure mode
1056
- * documented on the borderBottom rules above, which this deliberately does
1057
- * NOT repeat).
1058
- *
1059
- * Idempotent: startInkTerminal() is itself idempotent (returns the existing
1060
- * handle without re-rendering), so this guards against attaching a second
1061
- * 'resize' listener if it's ever called again for the same process.
1062
- */
1063
- function attachResizeCleanup() {
1064
- if (resizeListenerAttached || !process.stdout.isTTY)
1065
- return;
1066
- resizeListenerAttached = true;
1067
- resizeDebouncer = createResizeDebouncer(RESIZE_SETTLE_MS, repaintAfterResizeSettle);
1068
- process.stdout.on('resize', resizeDebouncer.notify);
1069
- }
1070
- export function stopInkTerminal() {
1071
- if (resizeDebouncer) {
1072
- process.stdout.off('resize', resizeDebouncer.notify);
1073
- resizeDebouncer.dispose();
1074
- resizeDebouncer = null;
1075
- }
1076
- resizeListenerAttached = false;
1077
- inkInstance?.unmount();
1078
- inkInstance = null;
1079
- handle = null;
1080
- }
1081
- /**
1082
- * Resolves once Ink has flushed its pending frame to stdout.
1083
- *
1084
- * Ink batches renders and commits them on a timer (maxFps, 30 by default), so
1085
- * immediately after a `console.log` the frame on screen may still be one
1086
- * revision behind. Anything that reasons about the CURRENT cursor row — such
1087
- * as padToBottom deciding how many blank rows are needed — has to wait for
1088
- * that flush, otherwise it measures against a stale frame. Without this the
1089
- * startup padding was nondeterministic: the same terminal size would keep the
1090
- * banner on some runs and scroll its top row away on others.
1091
- */
1092
- export async function flushInkFrame() {
1093
- await inkInstance?.waitUntilRenderFlush();
1094
- }
1095
- export function getInkTerminal() {
1096
- return handle;
1097
- }