nexrall-code 0.5.116 → 0.5.118
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/index.js +1779 -1078
- package/package.json +1 -6
- package/dist/audit.js +0 -102
- package/dist/commands/auth.js +0 -134
- package/dist/commands/chat.js +0 -2715
- package/dist/commands/plugin.js +0 -393
- package/dist/commands/sessions.js +0 -62
- package/dist/commands/update.js +0 -174
- package/dist/permissions/handler.js +0 -332
- package/dist/terminal/terminalSetup.js +0 -395
- package/dist/trust.js +0 -137
- package/dist/ui/diff.js +0 -51
- package/dist/ui/inkReadlineAdapter.js +0 -116
- package/dist/ui/inkTerminal.js +0 -1097
- package/dist/ui/resizeRepaint.js +0 -36
- package/dist/ui/screen.js +0 -482
- package/dist/ui/theme.js +0 -385
- package/dist/ui/trustPrompt.js +0 -126
package/dist/ui/inkTerminal.js
DELETED
|
@@ -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
|
-
}
|