@aragon-agent/cli 0.6.7 → 0.6.8

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 (63) hide show
  1. package/README.md +72 -43
  2. package/dist/agent/controller.d.ts +10 -6
  3. package/dist/agent/controller.js +35 -48
  4. package/dist/agent/headless.d.ts +2 -1
  5. package/dist/agent/prompt-options.d.ts +15 -0
  6. package/dist/agent/prompt-options.js +1 -0
  7. package/dist/cli.js +83 -53
  8. package/dist/commands/builtins.js +3 -1
  9. package/dist/commands/registry.d.ts +2 -3
  10. package/dist/input/interrupt-gesture.d.ts +18 -0
  11. package/dist/input/interrupt-gesture.js +19 -0
  12. package/dist/input/pointer-router.d.ts +11 -0
  13. package/dist/input/pointer-router.js +14 -0
  14. package/dist/todo/store.d.ts +8 -13
  15. package/dist/todo/store.js +13 -14
  16. package/dist/todo/types.d.ts +2 -1
  17. package/dist/ui/App.d.ts +2 -0
  18. package/dist/ui/App.js +166 -128
  19. package/dist/ui/Composer.d.ts +11 -5
  20. package/dist/ui/Composer.js +7 -11
  21. package/dist/ui/PromptCaret.d.ts +11 -0
  22. package/dist/ui/PromptCaret.js +31 -0
  23. package/dist/ui/PromptInput.d.ts +9 -6
  24. package/dist/ui/PromptInput.js +34 -14
  25. package/dist/ui/StatusBar.d.ts +1 -0
  26. package/dist/ui/StatusBar.js +14 -8
  27. package/dist/ui/Transcript.js +1 -0
  28. package/dist/ui/composer-rows.js +50 -9
  29. package/dist/ui/frame-differ.js +2 -19
  30. package/dist/ui/frame-observer.d.ts +17 -0
  31. package/dist/ui/frame-observer.js +81 -0
  32. package/dist/ui/frame-parser.d.ts +7 -0
  33. package/dist/ui/frame-parser.js +18 -0
  34. package/dist/ui/layout/AppShell.d.ts +7 -52
  35. package/dist/ui/layout/AppShell.js +2 -2
  36. package/dist/ui/layout/ScrollIndicator.d.ts +4 -3
  37. package/dist/ui/layout/ScrollIndicator.js +4 -4
  38. package/dist/ui/layout/ScrollViewport.d.ts +13 -66
  39. package/dist/ui/layout/ScrollViewport.js +183 -185
  40. package/dist/ui/layout/budget.d.ts +3 -51
  41. package/dist/ui/layout/budget.js +4 -53
  42. package/dist/ui/layout/document-layout.d.ts +13 -0
  43. package/dist/ui/layout/document-layout.js +11 -0
  44. package/dist/ui/layout/follow-state.d.ts +1 -0
  45. package/dist/ui/layout/follow-state.js +1 -3
  46. package/dist/ui/layout/scroll-indicator.d.ts +1 -1
  47. package/dist/ui/layout/scroll-indicator.js +3 -2
  48. package/dist/ui/layout/scrollbar-geometry.d.ts +17 -0
  49. package/dist/ui/layout/scrollbar-geometry.js +13 -0
  50. package/dist/ui/layout/todo-layout.d.ts +1 -0
  51. package/dist/ui/layout/todo-layout.js +3 -2
  52. package/dist/ui/layout/viewport-geometry.d.ts +1 -0
  53. package/dist/ui/layout/viewport-geometry.js +1 -0
  54. package/dist/ui/layout/virtual-window.d.ts +1 -0
  55. package/dist/ui/layout/virtual-window.js +1 -1
  56. package/dist/ui/overlays/HelpOverlay.js +5 -2
  57. package/dist/ui/screen.d.ts +1 -1
  58. package/dist/ui/screen.js +4 -6
  59. package/dist/ui/scrollbar-controller.d.ts +30 -0
  60. package/dist/ui/scrollbar-controller.js +124 -0
  61. package/dist/ui/use-wheel-routing.d.ts +2 -0
  62. package/dist/ui/use-wheel-routing.js +6 -0
  63. package/package.json +2 -2
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useLayoutEffect, useState } from 'react';
3
+ import { Text } from 'ink';
4
+ import { displayWidth } from './composer-rows.js';
5
+ function monochromeMarker(text) {
6
+ // Ink's output grid advances for combining marks, even though they have no
7
+ // terminal width. Retain them in both phases so its cell count stays stable.
8
+ const replace = (marker) => Array.from(text, (point) => {
9
+ const width = displayWidth(point);
10
+ return width > 0 ? marker.repeat(width) : point;
11
+ }).join('');
12
+ const underline = replace('_');
13
+ return underline === text ? replace('^') : underline;
14
+ }
15
+ /** The only blinking state belongs to this leaf, never to the editor. */
16
+ export function PromptCaret({ text, active, reducedMotion, colorLevel, color, resetKey, }) {
17
+ const [visible, setVisible] = useState(true);
18
+ useLayoutEffect(() => {
19
+ setVisible(true);
20
+ if (!active || reducedMotion)
21
+ return;
22
+ const timer = setInterval(() => setVisible((previous) => !previous), 500);
23
+ return () => clearInterval(timer);
24
+ }, [resetKey, active, reducedMotion]);
25
+ const bright = active && (reducedMotion || visible);
26
+ let content = text;
27
+ if (bright && colorLevel === 0) {
28
+ content = monochromeMarker(text);
29
+ }
30
+ return _jsx(Text, { color: color, inverse: bright && colorLevel > 0, children: content });
31
+ }
@@ -31,6 +31,9 @@ export interface CommandOption {
31
31
  description: string;
32
32
  }
33
33
  interface PromptInputProps {
34
+ cols?: number;
35
+ cursorVisible?: boolean;
36
+ onInteraction?: () => void;
34
37
  isActive: boolean;
35
38
  running: boolean;
36
39
  history: string[];
@@ -41,11 +44,8 @@ interface PromptInputProps {
41
44
  /**
42
45
  * Row budget for the completion popup (§5.2 / R-14).
43
46
  *
44
- * The popup renders INSIDE this component, inside the shell's `flexShrink={0}`
45
- * bottom chrome, and can add up to 9 rows. On a short terminal that pushes the
46
- * status bar past the frame height, where the root box's `overflow: hidden`
47
- * clips it away — the one piece of chrome R2 promises is always visible.
48
- * `App` derives this from `budget.ts` so the popup shrinks instead.
47
+ * The popup shares the scrolling footer with the editor. Its height budget
48
+ * reserves visible message rows without changing the viewport or TODO rail.
49
49
  */
50
50
  popupMaxRows?: number;
51
51
  /** Total menu rows, including borders and the overflow hint. */
@@ -53,6 +53,9 @@ interface PromptInputProps {
53
53
  onPopupRowsChange?: (rows: number) => void;
54
54
  onSubmit: (text: string) => void;
55
55
  onHelp?: () => void;
56
+ onEscape?: () => void;
57
+ onEscapeDismiss?: () => void;
58
+ reducedMotion?: boolean;
56
59
  /**
57
60
  * Mirror of the session mode. Read for the placeholder only — the toggle
58
61
  * itself belongs to `App`, and this component's job is to get out of its way
@@ -176,5 +179,5 @@ export declare function fileTokenAt(buffer: string, cursor: number): {
176
179
  end: number;
177
180
  query: string;
178
181
  } | null;
179
- export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, agentMode, onSubmit, onHelp, bordered, onDraftChange, onNotice, scrolledLines, }: PromptInputProps): React.ReactElement;
182
+ export declare function PromptInput({ cols: colsProp, cursorVisible, onInteraction, isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, agentMode, onSubmit, onHelp, onEscape, onEscapeDismiss, reducedMotion, bordered, onDraftChange, onNotice, scrolledLines, }: PromptInputProps): React.ReactElement;
180
183
  export {};
@@ -19,10 +19,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
19
19
  * stays a plain `useState` because it is written from the debounced glob effect
20
20
  * rather than from a key.
21
21
  */
22
- import { useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react';
22
+ import { useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
23
23
  import { Box, Text, useInput, useStdout } from 'ink';
24
24
  import { glob as tinyGlob } from 'tinyglobby';
25
25
  import { pickGlyphs } from './glyphs.js';
26
+ import { PromptCaret } from './PromptCaret.js';
26
27
  import { recognize } from '../input/keymap.js';
27
28
  import { AutocompletePopup } from './AutocompletePopup.js';
28
29
  import { buildAutocompleteLayout } from './layout/autocomplete.js';
@@ -43,8 +44,8 @@ const CHIP_FULL_MIN_COLS = 64;
43
44
  * reads `999+`.
44
45
  */
45
46
  const CHIP_MAX_N = 999;
46
- /** `↓ 999+ new lines · PgDn` — the widest form the full branch can produce. */
47
- const CHIP_FULL_CELLS = 24;
47
+ /** `↓ 999+ lines below · PgDn` — the widest form the full branch can produce. */
48
+ const CHIP_FULL_CELLS = 27;
48
49
  /** `↓999+` — the widest form the terse branch can produce. */
49
50
  const CHIP_TERSE_CELLS = 6;
50
51
  /**
@@ -64,7 +65,7 @@ export function scrollChip(scrolledLines, cols, glyphs) {
64
65
  }
65
66
  const noun = n === 1 ? 'line' : 'lines';
66
67
  return {
67
- text: `${glyphs.arrowDown} ${shown} new ${noun} ${glyphs.midDot} PgDn`,
68
+ text: `${glyphs.arrowDown} ${shown} ${noun} below ${glyphs.midDot} PgDn`,
68
69
  cells: CHIP_FULL_CELLS,
69
70
  };
70
71
  }
@@ -247,7 +248,7 @@ function isControlSeq(input) {
247
248
  // ---------------------------------------------------------------------------
248
249
  // Component
249
250
  // ---------------------------------------------------------------------------
250
- export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, agentMode, onSubmit, onHelp, bordered, onDraftChange, onNotice, scrolledLines = 0, }) {
251
+ export function PromptInput({ cols: colsProp, cursorVisible = true, onInteraction, isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, agentMode, onSubmit, onHelp, onEscape, onEscapeDismiss, reducedMotion = false, bordered, onDraftChange, onNotice, scrolledLines = 0, }) {
251
252
  const { stdout } = useStdout();
252
253
  const [editor, dispatch] = useReducer(editorReducer, INITIAL_EDITOR_STATE);
253
254
  const { buffer, cursor, historyIndex, dismissed, sel } = editor;
@@ -314,7 +315,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
314
315
  // Deriving it twice — once for the frame and once for the budget — is exactly
315
316
  // the trap `BottomStatusRow.tsx:5-27` documents.
316
317
  const glyphs = pickGlyphs(caps);
317
- const cols = stdout?.columns ?? 80;
318
+ const cols = colsProp ?? stdout?.columns ?? 80;
318
319
  const terminalRows = stdout?.rows ?? 24;
319
320
  // Full-screen only: inline mode has no self-drawn viewport, so `scrolledLines`
320
321
  // is always 0 there and the branch is unreachable rather than merely unused.
@@ -330,7 +331,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
330
331
  cursor,
331
332
  cols: wrapCols,
332
333
  maxRows: draftMaxRows(terminalRows),
333
- active: isActive,
334
+ active: isActive && cursorVisible,
334
335
  });
335
336
  // TWO PASSES, AND THE SECOND ONE IS LOAD-BEARING (I-8). The overflow indicator
336
337
  // rides the last rendered row INSIDE the box the draft wraps in, so its cells
@@ -348,6 +349,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
348
349
  const usableCols = overflows ? Math.max(8, baseCols - OVERFLOW_CELLS - 1) : baseCols;
349
350
  const layout = overflows ? measure(usableCols) : wide;
350
351
  const draftRowCount = Math.max(1, layout.rows.length);
352
+ const caretResetKey = useMemo(() => ({}), [buffer, cursor, isActive, usableCols]);
351
353
  // --- Draft presence and height, reported only on a transition. -----------
352
354
  // Notifying on every keystroke would re-render the whole App tree per key. The
353
355
  // row count changes on a line break or a wrap boundary, not per character
@@ -368,6 +370,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
368
370
  // by line. Two dispatches from one key would put the legacy root back to two
369
371
  // commits and undo the whole of F3 for that branch.
370
372
  const insert = (text) => {
373
+ onInteraction?.();
371
374
  dispatch({ type: 'insert', text });
372
375
  };
373
376
  /**
@@ -379,6 +382,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
379
382
  * needs. `clear` releases every payload in the same dispatch.
380
383
  */
381
384
  const submit = (text) => {
385
+ onInteraction?.();
382
386
  onSubmit(expandPastes(text, editor.pastes));
383
387
  dispatch({ type: 'clear' });
384
388
  };
@@ -404,6 +408,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
404
408
  dispatch({ type: 'recall', buffer: value, cursor: value.length, historyIndex: idx });
405
409
  };
406
410
  const completeSelection = () => {
411
+ onInteraction?.();
407
412
  const chosen = popupItems[clampedSel];
408
413
  if (!chosen)
409
414
  return;
@@ -417,6 +422,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
417
422
  }
418
423
  };
419
424
  const verticalOrHistory = (dir) => {
425
+ onInteraction?.();
420
426
  const moved = moveVertical(buffer, cursor, dir);
421
427
  if (moved) {
422
428
  dispatch({ type: 'moveCursor', cursor: moved.cursor });
@@ -441,13 +447,17 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
441
447
  // appears with a popup open and is therefore easy to ship (AC-P3 / R-P2).
442
448
  if (key.tab && key.shift)
443
449
  return;
450
+ if (key.pageUp || key.pageDown || (key.shift && (key.upArrow || key.downArrow)))
451
+ return;
444
452
  // Popup navigation owns Up/Down/Tab/→/Enter/Esc while it is open.
445
453
  if (popupVisible) {
446
454
  if (key.upArrow) {
455
+ onInteraction?.();
447
456
  dispatch({ type: 'select', sel: Math.max(0, clampedSel - 1) });
448
457
  return;
449
458
  }
450
459
  if (key.downArrow) {
460
+ onInteraction?.();
451
461
  dispatch({ type: 'select', sel: Math.min(popupItems.length - 1, clampedSel + 1) });
452
462
  return;
453
463
  }
@@ -463,15 +473,19 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
463
473
  }
464
474
  if (key.escape) {
465
475
  dispatch({ type: 'dismiss' });
476
+ onEscapeDismiss?.();
466
477
  return;
467
478
  }
468
479
  // Any other key falls through so typing keeps filtering the popup.
469
480
  }
470
- if (key.escape)
471
- return; // App owns abort / close-overlay.
481
+ if (key.escape) {
482
+ onEscape?.();
483
+ return;
484
+ }
472
485
  // Line-editing intents (Home/End/word/kill) recognized from raw keys.
473
486
  const intent = recognize(input, key);
474
487
  if (intent) {
488
+ onInteraction?.();
475
489
  const next = applyEdit(buffer, cursor, intent);
476
490
  dispatch({ type: 'replace', buffer: next.buffer, cursor: next.cursor });
477
491
  return;
@@ -489,6 +503,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
489
503
  return;
490
504
  }
491
505
  if (key.backspace || key.delete) {
506
+ onInteraction?.();
492
507
  // The `cursor > 0` guard lives in the reducer now, so this branch is one
493
508
  // unconditional dispatch and a no-op at the left edge returns the same
494
509
  // state object — which React bails out on, exactly as the old
@@ -497,11 +512,15 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
497
512
  return;
498
513
  }
499
514
  if (key.leftArrow) {
500
- dispatch({ type: 'moveCursor', cursor: Math.max(0, cursor - 1) });
515
+ onInteraction?.();
516
+ const step = cursor >= 2 && (buffer.codePointAt(cursor - 2) ?? 0) > 0xffff ? 2 : 1;
517
+ dispatch({ type: 'moveCursor', cursor: Math.max(0, cursor - step) });
501
518
  return;
502
519
  }
503
520
  if (key.rightArrow) {
504
- dispatch({ type: 'moveCursor', cursor: Math.min(buffer.length, cursor + 1) });
521
+ onInteraction?.();
522
+ const step = (buffer.codePointAt(cursor) ?? 0) > 0xffff ? 2 : 1;
523
+ dispatch({ type: 'moveCursor', cursor: Math.min(buffer.length, cursor + step) });
505
524
  return;
506
525
  }
507
526
  // `!key.shift` is load-bearing: Shift+↑/↓ is the viewport's line-scroll
@@ -539,6 +558,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
539
558
  onNotice?.('warn', refusal);
540
559
  return;
541
560
  }
561
+ onInteraction?.();
542
562
  dispatch({ type: 'input', segments });
543
563
  return;
544
564
  }
@@ -549,7 +569,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
549
569
  const marker = running ? glyphs.steer : glyphs.caret;
550
570
  const markerColor = running ? theme.toolRunning : theme.primary;
551
571
  const placeholder = running
552
- ? `Type to steer the run, Esc to abort${glyphs.ellipsis}`
572
+ ? `Type to steer the run, Esc twice to interrupt${glyphs.ellipsis}`
553
573
  : agentMode === 'plan'
554
574
  ? `Describe what you want to build; I'll research and plan it first${glyphs.ellipsis}`
555
575
  : `Send a message (/ for commands, @ for files)${glyphs.ellipsis}`;
@@ -562,9 +582,9 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
562
582
  // The caret is drawn as one inverse cell, split at the column
563
583
  // `layoutComposer` reported — the same width table it wrapped with (I-9).
564
584
  const split = splitRowAtColumn(row, layout.cursorCol);
565
- return (_jsxs(_Fragment, { children: [renderSegments(split.before, `b${i}.`), _jsx(Text, { inverse: true, children: split.at ? split.at.text : ' ' }), renderSegments(split.after, `a${i}.`)] }));
585
+ return (_jsxs(_Fragment, { children: [renderSegments(split.before, `b${i}.`), _jsx(PromptCaret, { text: split.at ? split.at.text : ' ', active: isActive && cursorVisible, reducedMotion: reducedMotion, colorLevel: caps.colorLevel, resetKey: caretResetKey, color: split.at?.kind === 'token' ? tokenColor : undefined }), renderSegments(split.after, `a${i}.`)] }));
566
586
  });
567
- const inputRow = (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: markerColor, bold: true, children: [marker, ' '] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: buffer.length === 0 ? (_jsx(Text, { wrap: "truncate", color: theme.muted, children: placeholder })) : (rowNodes.map((node, i) => overflow && i === rowNodes.length - 1 ? (
587
+ const inputRow = (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: markerColor, bold: true, children: [marker, ' '] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: buffer.length === 0 ? (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [_jsx(PromptCaret, { text: String.fromCodePoint(placeholder.codePointAt(0)), active: isActive && cursorVisible, reducedMotion: reducedMotion, colorLevel: caps.colorLevel, color: theme.muted, resetKey: caretResetKey }), placeholder.slice(String.fromCodePoint(placeholder.codePointAt(0)).length)] })) : (rowNodes.map((node, i) => overflow && i === rowNodes.length - 1 ? (
568
588
  // The overflow indicator rides the LAST rendered row rather than
569
589
  // taking one of its own: an extra row would put the composer's
570
590
  // real height one above the `draftRows` it just reported (I-8).
@@ -21,6 +21,7 @@ import type { UsageTotal } from '../agent/reducer.js';
21
21
  import type { ContextUsageSnapshot } from '../compaction/types.js';
22
22
  import { type AgentMode } from '../agent/agent-mode.js';
23
23
  interface StatusBarProps {
24
+ interruptHint?: string;
24
25
  model: string;
25
26
  provider: string;
26
27
  usageTotal: UsageTotal;
@@ -1,4 +1,5 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import stringWidth from 'string-width';
2
3
  import { Box, Text, useStdout } from 'ink';
3
4
  import { pickGlyphs } from './glyphs.js';
4
5
  import { formatCost, formatTokens, formatDuration, promptTokensOf } from '../agent/usage.js';
@@ -95,6 +96,11 @@ export function StatusBar(props) {
95
96
  : pendingAgentMode
96
97
  ? `${MODE_LABEL[agentMode]} ${glyphs.arrowRight} ${MODE_LABEL[pendingAgentMode]}`
97
98
  : MODE_LABEL[agentMode];
99
+ const actionHints = [running ? (props.interruptHint ?? 'Esc abort') : '',
100
+ servicesActive ? `Ctrl+C stop ${servicesActive.live}` : '',
101
+ scrolledLines > 0 ? 'PgDn down' : ''].filter(Boolean).join(' | ');
102
+ const hintCols = actionHints ? Math.min(Math.max(0, cols - 1), stringWidth(actionHints) + 2) : 0;
103
+ const detailCols = Math.max(0, cols - 1 - hintCols);
98
104
  return (
99
105
  // `flexShrink={0}` on the left cluster is not cosmetic. When the two clusters
100
106
  // over-subscribe the row, yoga shrinks the flexible children and Ink's text
@@ -102,11 +108,11 @@ export function StatusBar(props) {
102
108
  // and "? hel" rather than truncating cleanly at one end. Pinning the left
103
109
  // cluster keeps the run status and model intact and makes the degradation
104
110
  // land in one predictable place.
105
- _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [redrawChar(redrawNonce), statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), teamActive && (_jsxs(Text, { color: theme.toolRunning, bold: true, children: [' ', cols >= TEAM_LIMITS.statusCompactCols
106
- ? `agents ${teamActive.running}/${teamActive.total}`
107
- : `[${teamActive.running}]`] })), retryActive && (_jsxs(Text, { color: theme.noticeWarn, bold: true, children: [' ', formatRetryChip(retryActive, cols)] })), todoActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= TODO_LIMITS.statusCompactCols
108
- ? `todo ${todoActive.done}/${todoActive.total}`
109
- : `[${todoActive.done}/${todoActive.total}]`] })), servicesActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= PROC_LIMITS.statusCompactCols
110
- ? `svc ${servicesActive.live}`
111
- : `[${servicesActive.live}]`] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [compactionActive && cols >= COMPACTION_LIMITS.statusCompactCols && (_jsxs(Text, { color: compactionActive.inFlight ? theme.accent : theme.muted, children: [compactionActive.inFlight ? 'compacting' : 'compact', ' '] })), fastActive && cols >= FAST_LIMITS.statusCompactCols && (_jsxs(Text, { color: theme.muted, children: ["fast", fastActive.inFlight ? '*' : '', ' '] })), ecoRung > 0 && cols >= ECO_MIN_COLS && (_jsxs(Text, { color: theme.muted, children: ["eco", ' '] })), scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= ABSOLUTE_PAIR_MIN_COLS && (_jsxs(Text, { color: theme.muted, children: [" ", absolutePair] })), cols >= SESSION_TOTAL_MIN_COLS && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
111
+ _jsxs(Box, { width: cols, flexDirection: "row", justifyContent: "space-between", flexShrink: 0, height: 1, overflow: "hidden", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(redrawNonce) }) }), hintCols > 0 && (_jsx(Box, { width: hintCols, flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [actionHints, ' '] }) })), _jsxs(Box, { display: detailCols > 0 ? 'flex' : 'none', width: detailCols, flexShrink: 0, flexDirection: "row", justifyContent: "space-between", overflow: "hidden", children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, overflow: "hidden", children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), teamActive && (_jsxs(Text, { color: theme.toolRunning, bold: true, children: [' ', cols >= TEAM_LIMITS.statusCompactCols
112
+ ? `agents ${teamActive.running}/${teamActive.total}`
113
+ : `[${teamActive.running}]`] })), retryActive && (_jsxs(Text, { color: theme.noticeWarn, bold: true, children: [' ', formatRetryChip(retryActive, cols)] })), todoActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= TODO_LIMITS.statusCompactCols
114
+ ? `todo ${todoActive.done}/${todoActive.total}`
115
+ : `[${todoActive.done}/${todoActive.total}]`] })), servicesActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= PROC_LIMITS.statusCompactCols
116
+ ? `svc ${servicesActive.live}`
117
+ : `[${servicesActive.live}]`] })), cols >= 60 && (cols >= 110 || (!running && !servicesActive && scrolledLines === 0)) && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [compactionActive && cols >= COMPACTION_LIMITS.statusCompactCols && (_jsxs(Text, { color: compactionActive.inFlight ? theme.accent : theme.muted, children: [compactionActive.inFlight ? 'compacting' : 'compact', ' '] })), fastActive && cols >= FAST_LIMITS.statusCompactCols && (_jsxs(Text, { color: theme.muted, children: ["fast", fastActive.inFlight ? '*' : '', ' '] })), ecoRung > 0 && cols >= ECO_MIN_COLS && (_jsxs(Text, { color: theme.muted, children: ["eco", ' '] })), scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= ABSOLUTE_PAIR_MIN_COLS && (_jsxs(Text, { color: theme.muted, children: [" ", absolutePair] })), cols >= SESSION_TOTAL_MIN_COLS && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] })] }));
112
118
  }
@@ -336,6 +336,7 @@ export function TranscriptList({ entries, expandedToolIds, thinkingVisible, redu
336
336
  entries: visible,
337
337
  heightOf,
338
338
  viewportRows: geometry.viewportRows,
339
+ trailingContentRows: geometry.trailingContentRows,
339
340
  offset: geometry.offset,
340
341
  overscan: VIRTUAL_LIMITS.overscan,
341
342
  });
@@ -52,6 +52,32 @@ export function displayWidth(text) {
52
52
  w += charWidth(ch);
53
53
  return w;
54
54
  }
55
+ /** Normalize only the visual projection; the editor keeps its UTF-16 index. */
56
+ function visualCursor(buffer, cursor) {
57
+ let index = Math.max(0, Math.min(buffer.length, cursor));
58
+ const code = buffer.charCodeAt(index);
59
+ const previous = buffer.charCodeAt(index - 1);
60
+ if (code >= 0xdc00 && code <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff)
61
+ index -= 1;
62
+ if (index === buffer.length || buffer[index] === '\n')
63
+ return index;
64
+ const ch = String.fromCodePoint(buffer.codePointAt(index));
65
+ if (ch === '\t' || charWidth(ch) > 0)
66
+ return index;
67
+ const start = buffer.lastIndexOf('\n', index - 1) + 1;
68
+ let nearest = -1;
69
+ let i = start;
70
+ while (i < buffer.length && buffer[i] !== '\n') {
71
+ const point = String.fromCodePoint(buffer.codePointAt(i));
72
+ if (point === '\t' || charWidth(point) > 0) {
73
+ if (i > index)
74
+ return nearest >= 0 ? nearest : i;
75
+ nearest = i;
76
+ }
77
+ i += point.length;
78
+ }
79
+ return nearest >= 0 ? nearest : i;
80
+ }
55
81
  /**
56
82
  * Wrap `buffer` into rows, locate the caret, and select a window of at most
57
83
  * `maxRows` rows that contains it.
@@ -62,7 +88,8 @@ export function displayWidth(text) {
62
88
  * to live in the reducer and would then need its own invalidation rules.
63
89
  */
64
90
  export function layoutComposer(input) {
65
- const { buffer, cursor, active } = input;
91
+ const { buffer, active } = input;
92
+ const cursor = visualCursor(buffer, input.cursor);
66
93
  const cols = Math.max(1, Math.floor(input.cols));
67
94
  const maxRows = input.maxRows >= 1 ? Math.floor(input.maxRows) : 1;
68
95
  const spans = tokenSpans(buffer);
@@ -111,6 +138,7 @@ export function layoutComposer(input) {
111
138
  const ch = String.fromCodePoint(buffer.codePointAt(i));
112
139
  if (ch === '\n') {
113
140
  if (atCursor) {
141
+ ensureRoom(1);
114
142
  cursorRowAbs = rows.length;
115
143
  cursorCol = col;
116
144
  }
@@ -182,20 +210,33 @@ export function splitRowAtColumn(row, column) {
182
210
  else
183
211
  list.push({ text, kind });
184
212
  };
213
+ const cells = [];
214
+ let leading = '';
185
215
  for (const segment of row.segments) {
186
- if (at !== null) {
187
- push(after, segment.text, segment.kind);
188
- continue;
189
- }
190
216
  for (const ch of segment.text) {
191
- if (at === null && x === column) {
192
- at = { text: ch, kind: segment.kind };
217
+ if (charWidth(ch) === 0) {
218
+ const last = cells[cells.length - 1];
219
+ if (last)
220
+ cells[cells.length - 1] = { ...last, text: last.text + ch };
221
+ else
222
+ leading += ch;
193
223
  }
194
224
  else {
195
- push(at === null ? before : after, ch, segment.kind);
225
+ cells.push({ text: leading + ch, kind: segment.kind });
226
+ leading = '';
196
227
  }
197
- x += charWidth(ch);
198
228
  }
199
229
  }
230
+ // A zero-width-only row keeps its text before the explicitly budgeted space.
231
+ if (leading)
232
+ push(before, leading, row.segments[0]?.kind ?? 'text');
233
+ for (const cell of cells) {
234
+ const width = displayWidth(cell.text);
235
+ if (at === null && column >= x && column < x + width)
236
+ at = cell;
237
+ else
238
+ push(at === null ? before : after, cell.text, cell.kind);
239
+ x += width;
240
+ }
200
241
  return { before, at, after };
201
242
  }
@@ -38,6 +38,7 @@
38
38
  * mode of it firing spuriously is one extra full repaint.
39
39
  */
40
40
  import { MIN_FULLSCREEN_ROWS } from './layout/frame.js';
41
+ import { matchInkErasePrefix } from './frame-parser.js';
41
42
  // ---------------------------------------------------------------------------
42
43
  // Constants — exact, so no call site ever re-derives an escape sequence.
43
44
  // ---------------------------------------------------------------------------
@@ -55,18 +56,6 @@ const ERASE_DOWN = `${CSI}J`;
55
56
  /** DEC private mode 2026 — begin/end synchronized update (F2, §3.3). */
56
57
  const SYNC_BEGIN = `${CSI}?2026h`;
57
58
  const SYNC_END = `${CSI}?2026l`;
58
- /**
59
- * `eraseLines(n)` for any n ≥ 1, anchored at the start of the chunk.
60
- *
61
- * FALLBACK ONLY (P2-3). Whenever `prev !== null` the differ already knows the
62
- * exact prefix it expects — `eraseLinesPrefix(prev.length + 1)` is a
63
- * deterministic string — so the hot path is `chunk.startsWith(expected)`:
64
- * linear, unambiguous, and it collapses step 7's `erased !== prev.length + 1`
65
- * test into the same comparison. This regex is reached only on the first
66
- * diffable chunk after a session start or an `invalidate()`, where no
67
- * expectation exists yet.
68
- */
69
- const ERASE_PREFIX_RE = /^(?:\x1b\[2K(?:\x1b\[1A)?)+\x1b\[G/;
70
59
  function cursorTo(row) {
71
60
  return `${CSI}${row};1H`;
72
61
  }
@@ -88,9 +77,6 @@ export function eraseLinesPrefix(count) {
88
77
  }
89
78
  return out + CURSOR_LEFT;
90
79
  }
91
- function countErases(prefix) {
92
- return prefix.split(ERASE_LINE).length - 1;
93
- }
94
80
  export function createFrameDiffer(options) {
95
81
  /**
96
82
  * Last frame AS PAINTED — what is actually on the screen.
@@ -200,10 +186,7 @@ export function createFrameDiffer(options) {
200
186
  if (chunk.startsWith(expected))
201
187
  return { prefix: expected, erased: prev.length + 1 };
202
188
  }
203
- const m = ERASE_PREFIX_RE.exec(chunk);
204
- if (m === null)
205
- return null;
206
- return { prefix: m[0], erased: countErases(m[0]) };
189
+ return matchInkErasePrefix(chunk);
207
190
  };
208
191
  /**
209
192
  * Full repaint — the first diffable frame of a session, every invalidation
@@ -0,0 +1,17 @@
1
+ import type { ScrollbarBridge } from './scrollbar-controller.js';
2
+ export interface FrameSummary {
3
+ columns: number;
4
+ rows: number;
5
+ trackTop: number;
6
+ trackRows: number;
7
+ }
8
+ /** Observe before differ transformation, but acknowledge only after downstream write returns. */
9
+ export declare function createFrameObserver(options: {
10
+ stdout: NodeJS.WriteStream;
11
+ terminal: NodeJS.WriteStream;
12
+ scrollbar: ScrollbarBridge;
13
+ }): {
14
+ stdout: NodeJS.WriteStream;
15
+ dispose(): void;
16
+ invalidate(): void;
17
+ };
@@ -0,0 +1,81 @@
1
+ import stripAnsi from 'strip-ansi';
2
+ import stringWidth from 'string-width';
3
+ import { parseInkFrame } from './frame-parser.js';
4
+ import { pickGlyphs } from './glyphs.js';
5
+ function summarizeFrame(chunk, rows, cols) {
6
+ const lines = typeof chunk === 'string' ? parseInkFrame(chunk) : null;
7
+ if (!lines || rows < 12 || cols < 40 || lines.length !== rows - 1)
8
+ return null;
9
+ const unicode = pickGlyphs({ unicode: true, colorLevel: 0 });
10
+ const cells = new Set(['|', '#', unicode.scrollTrack, unicode.scrollThumb]);
11
+ const plain = lines.map((line) => stripAnsi(line));
12
+ const track = plain.slice(1, rows - 3);
13
+ if (plain.some((line) => stringWidth(line) > cols) ||
14
+ !track.every((line) => stringWidth(line) === cols && cells.has(line.slice(-1))))
15
+ return null;
16
+ return { columns: cols, rows, trackTop: 2, trackRows: rows - 4 };
17
+ }
18
+ /** Observe before differ transformation, but acknowledge only after downstream write returns. */
19
+ export function createFrameObserver(options) {
20
+ const { stdout, terminal, scrollbar } = options;
21
+ let summary = null;
22
+ let disposed = false;
23
+ let resizeTimer = null;
24
+ const invalidate = () => { summary = null; scrollbar.invalidate(); };
25
+ const onResize = () => {
26
+ invalidate();
27
+ if (resizeTimer)
28
+ clearTimeout(resizeTimer);
29
+ // Ink suppresses identical frames when a resize burst returns to its starting size.
30
+ // Wait past the 50 ms React size debounce, then request a normal redraw if needed.
31
+ resizeTimer = setTimeout(() => {
32
+ resizeTimer = null;
33
+ if (!disposed && !scrollbar.frameReady)
34
+ scrollbar.requestRedraw?.();
35
+ }, 60);
36
+ resizeTimer.unref?.();
37
+ };
38
+ const match = () => {
39
+ const g = scrollbar.geometry;
40
+ scrollbar.frameReady = !!summary && !!g && summary.columns === terminal.columns &&
41
+ summary.rows === terminal.rows && g.trackCol === summary.columns &&
42
+ g.trackTop === summary.trackTop && g.trackRows === summary.trackRows;
43
+ };
44
+ const observe = (chunk) => {
45
+ summary = summarizeFrame(chunk, terminal.rows, terminal.columns);
46
+ if (!summary) {
47
+ invalidate();
48
+ return;
49
+ }
50
+ match();
51
+ };
52
+ scrollbar.onGeometry = match;
53
+ terminal.on('resize', onResize);
54
+ const bound = new Map();
55
+ const proxy = new Proxy(stdout, {
56
+ get(target, prop) {
57
+ if (prop === 'write')
58
+ return (...args) => {
59
+ const result = Reflect.apply(target.write, target, args);
60
+ if (!disposed)
61
+ observe(args[0]);
62
+ return result;
63
+ };
64
+ const value = Reflect.get(target, prop, target);
65
+ if (typeof value !== 'function')
66
+ return value;
67
+ if (!bound.has(prop))
68
+ bound.set(prop, value.bind(target));
69
+ return bound.get(prop);
70
+ },
71
+ });
72
+ return { stdout: proxy, invalidate, dispose: () => {
73
+ disposed = true;
74
+ if (resizeTimer)
75
+ clearTimeout(resizeTimer);
76
+ resizeTimer = null;
77
+ terminal.off('resize', onResize);
78
+ scrollbar.onGeometry = undefined;
79
+ invalidate();
80
+ } };
81
+ }
@@ -0,0 +1,7 @@
1
+ /** Recognize Ink's erase-lines prefix without accepting arbitrary control writes. */
2
+ export declare function matchInkErasePrefix(chunk: string): {
3
+ prefix: string;
4
+ erased: number;
5
+ } | null;
6
+ /** Parse complete frames, including Ink's prefixless initial frame. */
7
+ export declare function parseInkFrame(chunk: string): string[] | null;
@@ -0,0 +1,18 @@
1
+ /** Recognize Ink's erase-lines prefix without accepting arbitrary control writes. */
2
+ export function matchInkErasePrefix(chunk) {
3
+ const match = /^(?:\x1b\[2K(?:\x1b\[1A)?)+\x1b\[G/.exec(chunk);
4
+ return match ? { prefix: match[0], erased: match[0].split('\x1b[2K').length - 1 } : null;
5
+ }
6
+ /** Parse complete frames, including Ink's prefixless initial frame. */
7
+ export function parseInkFrame(chunk) {
8
+ const prefix = matchInkErasePrefix(chunk);
9
+ const body = chunk.slice(prefix?.prefix.length ?? 0);
10
+ if (!body.endsWith('\n'))
11
+ return null;
12
+ // SGR and OSC-8 may decorate text; cursor movement is never frame content.
13
+ const controls = body.replace(/\x1b\[[0-9;:]*m/g, '')
14
+ .replace(/\x1b\]8;[^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
15
+ if (/[\x00-\x08\x0b-\x1f\x7f]/.test(controls))
16
+ return null;
17
+ return body.slice(0, -1).split('\n');
18
+ }