u-foo 3.0.9 → 3.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +25 -9
  2. package/README.zh-CN.md +22 -9
  3. package/dist/tui/darwin-arm64/ufoo-tui +0 -0
  4. package/dist/tui/darwin-x64/ufoo-tui +0 -0
  5. package/dist/tui/linux-arm64/ufoo-tui +0 -0
  6. package/dist/tui/linux-x64/ufoo-tui +0 -0
  7. package/package.json +12 -4
  8. package/scripts/pack-tui.js +112 -0
  9. package/scripts/postinstall.js +11 -0
  10. package/src/agents/activity/activityReconcile.js +106 -0
  11. package/src/agents/activity/activityStatePublisher.js +31 -2
  12. package/src/agents/activity/index.js +1 -0
  13. package/src/agents/launch/launcher.js +19 -0
  14. package/src/agents/launch/ptyRunner.js +20 -1
  15. package/src/app/chat/ChatController.js +433 -0
  16. package/src/app/chat/agentDirectory.js +63 -0
  17. package/src/app/chat/agentEnter.js +70 -0
  18. package/src/app/chat/agentIdentity.js +50 -0
  19. package/src/app/chat/bootstrap.js +66 -0
  20. package/src/app/chat/commandExecutor.js +108 -0
  21. package/src/app/chat/commands.js +38 -1
  22. package/src/app/chat/dashboardView.js +6 -2
  23. package/src/app/chat/historyStore.js +181 -0
  24. package/src/app/chat/index.js +14 -2
  25. package/src/app/chat/inputSubmitHandler.js +21 -7
  26. package/src/app/chat/ipcBuilders.js +52 -0
  27. package/src/app/chat/multiWindow/paneManager.js +10 -1
  28. package/src/app/chat/multiWindow/renderer.js +1 -1
  29. package/src/app/chat/multiWindow/vtFrame.js +93 -0
  30. package/src/app/chat/streamState.js +182 -0
  31. package/src/app/cli/features/doctor.js +22 -0
  32. package/src/code/UcodeController.js +156 -0
  33. package/src/code/context/planGraphService.js +4 -0
  34. package/src/code/repl.js +4 -3
  35. package/src/code/runtime/taskLoop.js +46 -50
  36. package/src/code/tui.js +13 -2
  37. package/src/code/ucodeSlashDispatch.js +241 -0
  38. package/src/coordination/bus/activate.js +3 -0
  39. package/src/runtime/contracts/schemas/ufoo-ui-v1/envelope.json +28 -0
  40. package/src/runtime/contracts/uiProtocol.js +190 -0
  41. package/src/ui/{ink/chatLogModel.js → chatLogModel.js} +2 -2
  42. package/src/ui/dashboardBridge.js +81 -0
  43. package/src/ui/format/index.js +2 -2
  44. package/src/ui/index.js +8 -4
  45. package/src/ui/multiPaneBusMirror.js +137 -0
  46. package/src/ui/multiWindowHandoff.js +232 -0
  47. package/src/ui/ptyHandoff.js +23 -0
  48. package/src/ui/rustChatHost.js +1520 -0
  49. package/src/ui/rustMultiSession.js +497 -0
  50. package/src/ui/rustUcodeHost.js +999 -0
  51. package/src/ui/scrollbackReplay.js +82 -0
  52. package/src/ui/settingsBridge.js +49 -0
  53. package/src/ui/toolMergeBridge.js +66 -0
  54. package/src/ui/tuiLauncher.js +105 -0
  55. package/src/ui/ucodeStatusLine.js +74 -0
  56. package/src/ui/uiHostServer.js +339 -0
  57. package/src/ui/MIGRATION.md +0 -334
  58. package/src/ui/ink/ChatApp.js +0 -4152
  59. package/src/ui/ink/DashboardBar.js +0 -691
  60. package/src/ui/ink/InkDemo.js +0 -96
  61. package/src/ui/ink/MultilineInput.js +0 -662
  62. package/src/ui/ink/UcodeApp.js +0 -1675
  63. package/src/ui/ink/agentMirror.js +0 -730
  64. package/src/ui/ink/chatReducer.js +0 -473
  65. package/src/ui/runInk.js +0 -66
@@ -1,662 +0,0 @@
1
- "use strict";
2
-
3
- /**
4
- * Multiline text input for the ink-based ucode TUI.
5
- *
6
- * Built on ink's useInput. Cursor math is delegated to src/ui/format so
7
- * jest can cover the editor behaviour without mounting ink.
8
- *
9
- * Props:
10
- * value (string) text contents (controlled)
11
- * onChange(nextValue) called when value changes
12
- * onSubmit(value) Enter pressed without modifiers, no trailing `\`
13
- * onCancel() Esc pressed; the parent decides what to do
14
- * (e.g. abort an in-flight task). The component
15
- * does NOT mutate `value` on cancel.
16
- * onArrowUpAtTop(value) cursor on the first visual row, Up pressed
17
- * onArrowDownAtBottom(value) cursor on the last visual row, Down pressed
18
- * onArrowLeftAtEmpty(value) Left pressed while value is empty
19
- * onArrowRightAtEmpty(value) Right pressed while value is empty
20
- * width (number) wrap width in display cells
21
- * interactive (boolean) gates useInput; pass false for non-TTY mounts
22
- * interceptArrowsAndEnter (boolean)
23
- * when true, Up/Down/Left/Right/Return are
24
- * suppressed inside the editor so a parent
25
- * component (e.g. completion popup) can
26
- * handle them. Plain editing keys still work.
27
- * placeholder (string) rendered in gray when value is empty
28
- * onPasteText(filtered) optional. Called for non-empty paste/insert
29
- * chunks before insertText. May return:
30
- * string — insert that text instead
31
- * { text } — insert text (may be "")
32
- * null/undefined — fall back to filtered text
33
- *
34
- * Newlines: Enter submits. Use Alt+Enter (delivered as meta+Return) or end the
35
- * line with `\` (the legacy continuation trick) to insert a literal newline.
36
- * We do NOT rely on Shift+Enter — many terminals don't distinguish it from
37
- * plain Enter, so it would silently submit.
38
- *
39
- * Bracketed paste arrives as a multi-byte `input` chunk in useInput; we route
40
- * it through insertText (or onPasteText), so multi-line paste already works
41
- * without extra code.
42
- */
43
-
44
- const fmt = require("../format");
45
-
46
- // IME cursor parking interacts with ink's frame rendering. Two facts about
47
- // ink make the naive "move cursor up in useEffect" approach insufficient:
48
- //
49
- // 1. ink throttles onRender to 32ms (ink.js:39), so frame writes happen
50
- // AFTER our useEffect, not before — anything we wrote in useEffect ends
51
- // up getting overwritten by ink's parking cursor at the bottom of the
52
- // next frame, which is what the user sees as "光标被结尾抢走".
53
- //
54
- // 2. ink's log-update emits ansi-escapes.eraseLines(N) before each frame:
55
- // a sequence of `eraseLine + cursorUp` pairs starting from "wherever
56
- // the cursor currently is". If our IME hack left the cursor mid-frame,
57
- // ink's relative cursorUp walks past the top of the frame and tramples
58
- // lines above it.
59
- //
60
- // Fix: wrap stdout.write once. Before any frame-shaped write (starts with
61
- // ESC[2K from eraseLines, or ESC[2J from full-screen rerender), push the
62
- // cursor back DOWN to the parking row so ink's math is restored. AFTER the
63
- // frame write, if the IME park target is active, re-emit the cursor-up +
64
- // CHA so the hardware cursor follows the inverse caret again. This way the
65
- // caret stays parked at the IME-visible row even when ink's throttled write
66
- // fires long after React commit.
67
- const __imeStdoutState = new WeakSet();
68
- const __imeCursor = {
69
- active: false,
70
- showHardwareCursor: true,
71
- // Where to park the cursor: rowsUp above ink's "row after last frame line"
72
- // anchor, and 0-based terminal column.
73
- parkRowsUp: 0,
74
- parkCol: 0,
75
- // How many rows up we last actually moved the cursor — used to undo the
76
- // move before ink runs its relative eraseLines. ALWAYS matches the move
77
- // we last wrote, regardless of whether that was through the patched
78
- // stdout write or the useEffect path.
79
- movedUpRows: 0,
80
- // Tracks whether the LAST frame ink wrote ended with '\n' (the log-update
81
- // path) or not (the full-screen path). The anchor row is one row higher
82
- // when there's no trailing newline, which shifts subsequent restore-down
83
- // math by one. Without this flag, useEffect after a full-screen frame
84
- // restores down too far and overshoots upward → a "ghost caret" sits one
85
- // or more rows above the real caret.
86
- lastFrameHadNewline: true,
87
- };
88
-
89
- function isFrameWrite(chunk) {
90
- if (typeof chunk !== "string" && !(chunk instanceof String)) return false;
91
- const str = String(chunk);
92
- // eraseLines(N>0) starts with ESC[2K; full-screen clear starts with ESC[2J.
93
- return str.startsWith("\x1b[2K") || str.startsWith("\x1b[2J");
94
- }
95
-
96
- // Compute "rows up from the current anchor to the caret". The anchor sits
97
- // one row below the last frame line when the frame ended with '\n', and AT
98
- // the last frame line otherwise — so a frame with no trailing newline needs
99
- // one fewer row up to land on the caret.
100
- function rowsUpFromAnchor() {
101
- const base = __imeCursor.parkRowsUp;
102
- return __imeCursor.lastFrameHadNewline ? base : Math.max(0, base - 1);
103
- }
104
-
105
- function applyParkSequence(parkRowsUp) {
106
- if (!__imeCursor.active) return "";
107
- const up = parkRowsUp > 0 ? `\x1b[${parkRowsUp}A` : "";
108
- const col = `\x1b[${__imeCursor.parkCol + 1}G`; // CHA is 1-based
109
- __imeCursor.movedUpRows = parkRowsUp;
110
- const visibility = __imeCursor.showHardwareCursor ? "\x1b[?25h" : "\x1b[?25l";
111
- return `${up}${col}${visibility}`;
112
- }
113
-
114
- function patchStdoutForIME(out) {
115
- if (!out || typeof out.write !== "function" || __imeStdoutState.has(out)) {
116
- return;
117
- }
118
- __imeStdoutState.add(out);
119
- const originalWrite = out.write.bind(out);
120
- out.write = function patchedWrite(chunk, encoding, callback) {
121
- if (!isFrameWrite(chunk) || (typeof chunk !== "string" && !(chunk instanceof String))) {
122
- return originalWrite(chunk, encoding, callback);
123
- }
124
- // Combine "hide cursor + restore-to-anchor + ink's frame + reposition +
125
- // show cursor" into a SINGLE write so the terminal processes the whole
126
- // transition atomically. With ink's eraseLines walking the cursor up
127
- // through the frame mid-write, even one stray byte between escape
128
- // sequences can leave the hardware cursor visible on an intermediate
129
- // row for a frame — exactly the "faint cursor above the real one"
130
- // ghost the user reports.
131
- const str = String(chunk);
132
- let prefix = "\x1b[?25l"; // hide cursor for the whole transition
133
- if (__imeCursor.movedUpRows > 0) {
134
- // Push the cursor back down to ink's "after last frame line" anchor
135
- // so the relative cursorUp inside eraseLines walks the right rows.
136
- prefix += `\x1b[${__imeCursor.movedUpRows}B`;
137
- __imeCursor.movedUpRows = 0;
138
- }
139
- // Record which ink path this frame took so subsequent restores-down know
140
- // where the anchor actually sits.
141
- __imeCursor.lastFrameHadNewline = str.endsWith("\n");
142
- const suffix = applyParkSequence(rowsUpFromAnchor()) || "\x1b[?25l";
143
- return originalWrite(prefix + str + suffix, encoding, callback);
144
- };
145
- }
146
-
147
- function createMultilineInput({ React, ink }) {
148
- const { useState, useCallback, useMemo, useEffect } = React;
149
- const { Box, Text, useInput, useStdout } = ink;
150
- const h = React.createElement;
151
-
152
- return function MultilineInput({
153
- value = "",
154
- valueVersion = 0,
155
- onChange = () => {},
156
- onSubmit = () => {},
157
- onCancel = () => {},
158
- onArrowUpAtTop,
159
- onArrowDownAtBottom,
160
- onArrowLeftAtEmpty,
161
- onArrowRightAtEmpty,
162
- width = 80,
163
- interactive = true,
164
- interceptArrowsAndEnter = false,
165
- placeholder = "",
166
- promptPrefix = "› ",
167
- promptColor = "magenta",
168
- borderColor = "gray",
169
- showHardwareCursor = true,
170
- // How many terminal rows of UI sit *below* the bottom of this input box
171
- // (status line, dashboard rows, etc.). The component uses this to compute
172
- // how far up the hardware cursor needs to be moved after each render so
173
- // the IME composition window pops up at the visible (inverse) cursor
174
- // instead of at the bottom of the screen.
175
- linesBelowInput = 0,
176
- onPasteText = null,
177
- }) {
178
- // Cursor is owned by this component. preferredCol tracks the visual
179
- // column we want to keep when bouncing across lines of different widths
180
- // via Up/Down.
181
- const [cursorState, setCursorState] = useState(() => String(value || "").length);
182
- const [preferredCol, setPreferredCol] = useState(null);
183
- const cursorPos = fmt.clampCursorPos(cursorState, value);
184
-
185
- // When the parent forces a new value via valueVersion (e.g. accepting
186
- // a completion), park the cursor at the end of the freshly inserted
187
- // text so the user can keep typing without arrow-keying back to the
188
- // tail.
189
- useEffect(() => {
190
- setCursorState(String(value || "").length);
191
- setPreferredCol(null);
192
- // eslint-disable-next-line react-hooks/exhaustive-deps
193
- }, [valueVersion]);
194
-
195
- const wrapWidth = Math.max(1, Math.floor(Number(width) || 80));
196
-
197
- const setCursor = useCallback((next) => {
198
- setCursorState(fmt.clampCursorPos(next, value));
199
- }, [value]);
200
- const resetPreferredCol = useCallback(() => setPreferredCol(null), []);
201
-
202
- const change = useCallback((nextValue, nextCursor) => {
203
- const clamped = fmt.clampCursorPos(nextCursor, nextValue);
204
- setCursorState(clamped);
205
- onChange(nextValue);
206
- }, [onChange]);
207
-
208
- const insertText = useCallback((text) => {
209
- const before = value.slice(0, cursorPos);
210
- const after = value.slice(cursorPos);
211
- change(`${before}${text}${after}`, cursorPos + text.length);
212
- setPreferredCol(null);
213
- }, [value, cursorPos, change]);
214
-
215
- const replaceRange = useCallback((start, end, text) => {
216
- const safeStart = Math.max(0, Math.min(value.length, start));
217
- const safeEnd = Math.max(safeStart, Math.min(value.length, end));
218
- const next = `${value.slice(0, safeStart)}${text}${value.slice(safeEnd)}`;
219
- change(next, safeStart + text.length);
220
- setPreferredCol(null);
221
- }, [value, change]);
222
-
223
- const deleteBefore = useCallback(() => {
224
- if (cursorPos <= 0) return;
225
- replaceRange(cursorPos - 1, cursorPos, "");
226
- }, [cursorPos, replaceRange]);
227
-
228
- const deleteAt = useCallback(() => {
229
- if (cursorPos >= value.length) return;
230
- replaceRange(cursorPos, cursorPos + 1, "");
231
- }, [cursorPos, value.length, replaceRange]);
232
-
233
- const deleteToBoundary = useCallback((boundary) => {
234
- const target = fmt.moveCursorToVisualLineBoundary({
235
- cursorPos,
236
- inputValue: value,
237
- width: wrapWidth,
238
- boundary,
239
- });
240
- const start = Math.min(cursorPos, target);
241
- const end = Math.max(cursorPos, target);
242
- if (start === end) return;
243
- replaceRange(start, end, "");
244
- }, [cursorPos, value, wrapWidth, replaceRange]);
245
-
246
- const deleteWordBefore = useCallback(() => {
247
- const next = fmt.deleteWordBeforeCursor(value, cursorPos);
248
- change(next.value, next.cursorPos);
249
- setPreferredCol(null);
250
- }, [value, cursorPos, change]);
251
-
252
- const deleteWordAfter = useCallback(() => {
253
- const end = fmt.moveCursorByWord(value, cursorPos, "forward");
254
- replaceRange(cursorPos, end, "");
255
- }, [value, cursorPos, replaceRange]);
256
-
257
- useInput((input, key) => {
258
- // Let the parent absorb arrow keys + Enter when a popup (e.g. the
259
- // completion list) is open. We still process plain text input so
260
- // typing continues to filter the popup live.
261
- if (interceptArrowsAndEnter && (
262
- key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return
263
- )) {
264
- return;
265
- }
266
- // Submit: Enter without modifiers, except after a trailing backslash
267
- // (which is the legacy "\\\n" continuation trick). Alt+Enter inserts
268
- // a literal newline. Shift+Enter is intentionally NOT used: many
269
- // terminals don't distinguish it from plain Enter.
270
- if (key.return) {
271
- if (key.meta) {
272
- insertText("\n");
273
- return;
274
- }
275
- if (cursorPos > 0 && value[cursorPos - 1] === "\\") {
276
- replaceRange(cursorPos - 1, cursorPos, "\n");
277
- return;
278
- }
279
- onSubmit(value);
280
- return;
281
- }
282
- if (key.escape) {
283
- onCancel();
284
- return;
285
- }
286
- if (key.ctrl) {
287
- if (input === "a") {
288
- setCursor(fmt.moveCursorToVisualLineBoundary({
289
- cursorPos, inputValue: value, width: wrapWidth, boundary: "start",
290
- }));
291
- resetPreferredCol();
292
- return;
293
- }
294
- if (input === "e") {
295
- setCursor(fmt.moveCursorToVisualLineBoundary({
296
- cursorPos, inputValue: value, width: wrapWidth, boundary: "end",
297
- }));
298
- resetPreferredCol();
299
- return;
300
- }
301
- if (input === "b") {
302
- setCursor(fmt.moveCursorHorizontally(cursorPos, value, "left"));
303
- resetPreferredCol();
304
- return;
305
- }
306
- if (input === "f") {
307
- setCursor(fmt.moveCursorHorizontally(cursorPos, value, "right"));
308
- resetPreferredCol();
309
- return;
310
- }
311
- if (input === "d") { deleteAt(); return; }
312
- if (input === "h") { deleteBefore(); return; }
313
- if (input === "k") { deleteToBoundary("end"); return; }
314
- if (input === "u") { deleteToBoundary("start"); return; }
315
- if (input === "w") { deleteWordBefore(); return; }
316
- // Ctrl+C is parent's responsibility (typically exits the app).
317
- return;
318
- }
319
- if (key.meta) {
320
- if (input === "b") {
321
- setCursor(fmt.moveCursorByWord(value, cursorPos, "backward"));
322
- resetPreferredCol();
323
- return;
324
- }
325
- if (input === "f") {
326
- setCursor(fmt.moveCursorByWord(value, cursorPos, "forward"));
327
- resetPreferredCol();
328
- return;
329
- }
330
- if (input === "d") { deleteWordAfter(); return; }
331
- }
332
- if (key.backspace) {
333
- if (key.meta || key.ctrl) deleteWordBefore();
334
- else deleteBefore();
335
- return;
336
- }
337
- if (key.delete) {
338
- // ink reports key.delete for the 0x7F byte that most terminals send
339
- // when the user presses the top-left Delete key (a.k.a. Backspace on
340
- // non-Mac keyboards). Treat it as "delete the character before the
341
- // cursor" by default. Real forward-delete (Fn+Delete on macOS) sends
342
- // an escape sequence and ink also sets key.delete with no leading
343
- // input — we can't reliably tell them apart, so favour the much
344
- // more common backspace semantics. Meta+Delete still maps to
345
- // delete-to-line-end as before.
346
- if (key.meta) deleteToBoundary("end");
347
- else deleteBefore();
348
- return;
349
- }
350
- if (key.leftArrow) {
351
- if (!value && typeof onArrowLeftAtEmpty === "function") {
352
- onArrowLeftAtEmpty(value);
353
- return;
354
- }
355
- setCursor(fmt.moveCursorHorizontally(cursorPos, value, "left"));
356
- resetPreferredCol();
357
- return;
358
- }
359
- if (key.rightArrow) {
360
- if (!value && typeof onArrowRightAtEmpty === "function") {
361
- onArrowRightAtEmpty(value);
362
- return;
363
- }
364
- setCursor(fmt.moveCursorHorizontally(cursorPos, value, "right"));
365
- resetPreferredCol();
366
- return;
367
- }
368
- if (key.upArrow) {
369
- if (value) {
370
- const move = fmt.moveCursorVertically({
371
- cursorPos, inputValue: value, width: wrapWidth,
372
- direction: "up", preferredCol,
373
- });
374
- setPreferredCol(move.preferredCol);
375
- if (move.moved) {
376
- setCursor(move.nextCursorPos);
377
- return;
378
- }
379
- }
380
- if (typeof onArrowUpAtTop === "function") onArrowUpAtTop(value);
381
- return;
382
- }
383
- if (key.downArrow) {
384
- if (value) {
385
- const move = fmt.moveCursorVertically({
386
- cursorPos, inputValue: value, width: wrapWidth,
387
- direction: "down", preferredCol,
388
- });
389
- setPreferredCol(move.preferredCol);
390
- if (move.moved) {
391
- setCursor(move.nextCursorPos);
392
- return;
393
- }
394
- }
395
- if (typeof onArrowDownAtBottom === "function") onArrowDownAtBottom(value);
396
- return;
397
- }
398
-
399
- // Plain character / paste. Filter control bytes.
400
- if (input && !key.ctrl && !key.meta) {
401
- const filtered = input.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, "");
402
- // Binary clipboard paste can strip to empty — still notify parent so
403
- // it can try macOS PNGf clipboard ingest.
404
- if (!filtered) {
405
- if (typeof onPasteText === "function") {
406
- try { onPasteText(""); } catch { /* ignore */ }
407
- }
408
- return;
409
- }
410
- if (typeof onPasteText === "function" && (filtered.length > 1 || filtered.includes("\n"))) {
411
- let rewritten;
412
- try {
413
- rewritten = onPasteText(filtered);
414
- } catch {
415
- rewritten = filtered;
416
- }
417
- if (rewritten == null) {
418
- insertText(filtered);
419
- return;
420
- }
421
- if (typeof rewritten === "string") {
422
- if (rewritten) insertText(rewritten);
423
- return;
424
- }
425
- if (rewritten && typeof rewritten === "object") {
426
- const nextText = rewritten.text == null ? filtered : String(rewritten.text);
427
- if (nextText) insertText(nextText);
428
- return;
429
- }
430
- }
431
- insertText(filtered);
432
- }
433
- }, { isActive: interactive });
434
-
435
- // Render: split into logical lines, then split each into visual rows by
436
- // wrap width. Highlight one cell at the cursor location. With a
437
- // placeholder, we still draw the cursor (visible at offset 0) and append
438
- // the placeholder text in gray after it.
439
- const showPlaceholder = !value && !!placeholder;
440
- const visualRows = useMemo(
441
- () => layoutRows(value, wrapWidth, cursorPos),
442
- [value, wrapWidth, cursorPos]
443
- );
444
-
445
- // Hardware-cursor parking for IME support. ink hides the terminal cursor
446
- // by default and parks it after the last frame line; macOS/Linux IMEs
447
- // (Pinyin, kkc, etc.) anchor the candidate window to the *hardware*
448
- // cursor, so without this hack Chinese input pops up at the bottom-right
449
- // instead of next to the inverse-block caret. We compute the row offset
450
- // of the inverse caret from the bottom of ink's rendered frame and emit
451
- // ANSI cursor-position escapes after every render.
452
- //
453
- // ink frame layout (top→bottom)
454
- // ... chat log ...
455
- // ┌── input border top ──┐ <- visualRows[0]
456
- // │ › row 0 │
457
- // │ row 1 │
458
- // └── input border bot ──┘
459
- // status line <- linesBelowInput rows
460
- // dashboard row(s)
461
- // <ink parks cursor here>
462
- const { stdout } = useStdout() || {};
463
- // Find the visual (row, col) of the cursor inside the wrapped layout.
464
- let cursorVisualRow = 0;
465
- let cursorVisualCol = 0;
466
- {
467
- let placed = false;
468
- for (let r = 0; r < visualRows.length && !placed; r += 1) {
469
- const row = visualRows[r];
470
- let col = 0;
471
- for (const seg of row.segments) {
472
- if (seg.cursor) {
473
- cursorVisualRow = r;
474
- cursorVisualCol = col;
475
- placed = true;
476
- break;
477
- }
478
- col += fmt.displayCellWidth(seg.text);
479
- }
480
- }
481
- if (!placed) {
482
- // Cursor at end-of-input on a fresh row.
483
- cursorVisualRow = Math.max(0, visualRows.length - 1);
484
- cursorVisualCol = 0;
485
- const lastRow = visualRows[cursorVisualRow];
486
- if (lastRow) {
487
- for (const seg of lastRow.segments) {
488
- if (seg.cursor) break;
489
- cursorVisualCol += fmt.displayCellWidth(seg.text);
490
- }
491
- }
492
- }
493
- }
494
- const promptCols = cursorVisualRow === 0
495
- ? fmt.displayCellWidth(promptPrefix)
496
- : 2; // " " indent on continuation rows
497
- const cursorTermCol = promptCols + cursorVisualCol; // 0-based column
498
-
499
- // Distance from the cursor's row to the parking row that ink will leave
500
- // behind: bottom border (1) + linesBelowInput + the trailing newline ink
501
- // appends to its frame string (1, see ink/log-update.js).
502
- const rowsBelowCursor = (visualRows.length - 1 - cursorVisualRow)
503
- + 1 // bottom border row of the input box
504
- + Math.max(0, Math.floor(Number(linesBelowInput) || 0))
505
- + 1; // ink appends "\n" after the frame, so the cursor sits one extra
506
- // line below the last printed row
507
-
508
- useEffect(() => {
509
- const out = stdout || process.stdout;
510
- if (!out || typeof out.write !== "function" || !out.isTTY) {
511
- __imeCursor.active = false;
512
- return undefined;
513
- }
514
- if (!interactive) {
515
- // Hand the cursor back to ink and stop chasing the caret.
516
- if (__imeCursor.movedUpRows > 0) {
517
- out.write(`\x1b[${__imeCursor.movedUpRows}B`);
518
- __imeCursor.movedUpRows = 0;
519
- }
520
- __imeCursor.active = false;
521
- return undefined;
522
- }
523
- patchStdoutForIME(out);
524
- const targetRowsUp = __imeCursor.lastFrameHadNewline
525
- ? rowsBelowCursor
526
- : Math.max(0, rowsBelowCursor - 1);
527
- const desiredShowHardwareCursor = showHardwareCursor !== false;
528
- const alreadyParked = __imeCursor.active === true
529
- && __imeCursor.parkRowsUp === rowsBelowCursor
530
- && __imeCursor.parkCol === cursorTermCol
531
- && __imeCursor.movedUpRows === targetRowsUp
532
- && __imeCursor.showHardwareCursor === desiredShowHardwareCursor;
533
- // Publish the desired park target so the stdout monkey-patch can
534
- // re-park after every throttled ink frame write.
535
- __imeCursor.active = true;
536
- __imeCursor.showHardwareCursor = desiredShowHardwareCursor;
537
- __imeCursor.parkRowsUp = rowsBelowCursor;
538
- __imeCursor.parkCol = cursorTermCol;
539
- if (alreadyParked) return undefined;
540
- // Park immediately — covers cases where ink has nothing to render
541
- // (output unchanged) and won't fire a frame write at all, and keeps
542
- // the caret visible between frames. Combine hide + restore + park +
543
- // show into a single write so the terminal never sees the cursor at
544
- // an intermediate row.
545
- //
546
- // CRITICAL: the move-up amount must match the anchor that movedUpRows
547
- // was measured against. If the last frame ended without '\n' (the
548
- // full-screen path), the anchor is one row higher than the log-update
549
- // case, so we use targetRowsUp rather than parkRowsUp directly.
550
- // Otherwise restoring down by movedUpRows then moving up parkRowsUp
551
- // overshoots by one and leaves the hardware cursor one row above the
552
- // inverse caret — the residual "ghost cursor" symptom.
553
- let combined = "\x1b[?25l";
554
- if (__imeCursor.movedUpRows > 0) {
555
- combined += `\x1b[${__imeCursor.movedUpRows}B`;
556
- __imeCursor.movedUpRows = 0;
557
- }
558
- combined += applyParkSequence(targetRowsUp);
559
- out.write(combined);
560
- return undefined;
561
- });
562
-
563
- // On unmount, return the cursor to ink's expected parking row (so the
564
- // next frame ink renders after us doesn't trample lines above its frame)
565
- // and re-hide it so the rest of ink's lifetime behaves as before.
566
- useEffect(() => () => {
567
- const out = stdout || process.stdout;
568
- __imeCursor.active = false;
569
- if (out && typeof out.write === "function" && out.isTTY) {
570
- const restore = __imeCursor.movedUpRows > 0
571
- ? `\x1b[${__imeCursor.movedUpRows}B`
572
- : "";
573
- __imeCursor.movedUpRows = 0;
574
- out.write(`${restore}\x1b[?25l`);
575
- }
576
- }, [stdout]);
577
-
578
- return h(Box, {
579
- borderStyle: "single",
580
- borderTop: true,
581
- borderBottom: true,
582
- borderLeft: false,
583
- borderRight: false,
584
- borderColor,
585
- flexDirection: "column",
586
- width: "100%",
587
- },
588
- ...visualRows.map((row, idx) =>
589
- h(Box, { key: `row-${idx}` },
590
- idx === 0 ? h(Text, { color: promptColor }, promptPrefix) : h(Text, null, " "),
591
- ...row.segments.map((seg, segIdx) =>
592
- h(Text, {
593
- key: `s-${segIdx}`,
594
- inverse: seg.cursor,
595
- color: showPlaceholder && idx === 0 && segIdx === 0 ? "gray" : undefined,
596
- }, seg.text)
597
- ),
598
- showPlaceholder && idx === 0
599
- ? h(Text, { color: "gray" }, placeholder)
600
- : null,
601
- )
602
- ),
603
- );
604
- };
605
- }
606
-
607
- /**
608
- * Lay out `value` into visual rows respecting `width`, and mark the cell at
609
- * `cursor` so the renderer can invert it. Returns:
610
- * [{ segments: [{ text, cursor }] }, ...]
611
- *
612
- * Cursor at end-of-input is rendered as an inverted space appended to the
613
- * final row. Newlines split rows but never appear in segments. Pass
614
- * `cursor < 0` to suppress the cursor entirely (used in placeholder mode).
615
- */
616
- function layoutRows(value, width, cursor) {
617
- const text = String(value == null ? "" : value);
618
- const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
619
- const rawCursor = Number(cursor);
620
- const showCursor = Number.isFinite(rawCursor) && rawCursor >= 0;
621
- const cursorIdx = showCursor
622
- ? Math.min(text.length, Math.floor(rawCursor))
623
- : -1;
624
-
625
- const rows = [];
626
- let row = { segments: [], cellsUsed: 0, cursorPlaced: false };
627
- const pushRow = () => {
628
- rows.push(row);
629
- row = { segments: [], cellsUsed: 0, cursorPlaced: false };
630
- };
631
-
632
- for (let i = 0; i < text.length; i += 1) {
633
- const ch = text[i];
634
- if (ch === "\n") {
635
- if (cursorIdx === i && !row.cursorPlaced) {
636
- row.segments.push({ text: " ", cursor: true });
637
- row.cursorPlaced = true;
638
- }
639
- pushRow();
640
- continue;
641
- }
642
- const w = fmt.displayCellWidth(ch);
643
- if (row.cellsUsed + w > safeWidth) pushRow();
644
- if (cursorIdx === i) {
645
- row.segments.push({ text: ch, cursor: true });
646
- row.cursorPlaced = true;
647
- } else {
648
- row.segments.push({ text: ch, cursor: false });
649
- }
650
- row.cellsUsed += w;
651
- }
652
- if (cursorIdx === text.length && !row.cursorPlaced) {
653
- row.segments.push({ text: " ", cursor: true });
654
- }
655
- rows.push(row);
656
- return rows;
657
- }
658
-
659
- module.exports = {
660
- createMultilineInput,
661
- layoutRows,
662
- };