@aragon-agent/cli 0.6.4 → 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 (119) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +163 -37
  3. package/dist/agent/controller.d.ts +49 -8
  4. package/dist/agent/controller.js +160 -48
  5. package/dist/agent/headless.d.ts +2 -1
  6. package/dist/agent/prompt-options.d.ts +15 -0
  7. package/dist/agent/prompt-options.js +1 -0
  8. package/dist/agent/reducer.d.ts +33 -19
  9. package/dist/agent/reducer.js +62 -71
  10. package/dist/agent/usage.d.ts +34 -0
  11. package/dist/agent/usage.js +43 -0
  12. package/dist/cli.js +131 -54
  13. package/dist/commands/builtins.js +12 -1
  14. package/dist/commands/registry.d.ts +2 -3
  15. package/dist/compaction/compactor.d.ts +60 -24
  16. package/dist/compaction/compactor.js +89 -61
  17. package/dist/compaction/context-command.d.ts +33 -0
  18. package/dist/compaction/context-command.js +166 -0
  19. package/dist/compaction/meter.d.ts +251 -0
  20. package/dist/compaction/meter.js +428 -0
  21. package/dist/compaction/pressure.d.ts +6 -0
  22. package/dist/compaction/pressure.js +4 -0
  23. package/dist/compaction/types.d.ts +41 -0
  24. package/dist/compaction/wiring.d.ts +47 -0
  25. package/dist/compaction/wiring.js +88 -27
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.js +15 -1
  28. package/dist/config/load.d.ts +17 -0
  29. package/dist/config/load.js +45 -1
  30. package/dist/config/schema.d.ts +44 -0
  31. package/dist/config/schema.js +36 -0
  32. package/dist/config/store.js +27 -1
  33. package/dist/exec/events.d.ts +138 -1
  34. package/dist/exec/events.js +42 -0
  35. package/dist/exec/index.js +43 -6
  36. package/dist/exec/progress-reporter.d.ts +23 -0
  37. package/dist/exec/progress-reporter.js +88 -0
  38. package/dist/exec/runner.d.ts +34 -0
  39. package/dist/exec/runner.js +137 -25
  40. package/dist/fast/reviewer.d.ts +19 -1
  41. package/dist/fast/reviewer.js +22 -0
  42. package/dist/fast/types.d.ts +15 -0
  43. package/dist/fast/wiring.d.ts +10 -1
  44. package/dist/fast/wiring.js +18 -1
  45. package/dist/input/interrupt-gesture.d.ts +18 -0
  46. package/dist/input/interrupt-gesture.js +19 -0
  47. package/dist/input/pointer-router.d.ts +11 -0
  48. package/dist/input/pointer-router.js +14 -0
  49. package/dist/todo/limits.d.ts +4 -17
  50. package/dist/todo/limits.js +4 -17
  51. package/dist/todo/panel-rows.d.ts +8 -6
  52. package/dist/todo/panel-rows.js +19 -6
  53. package/dist/todo/store.d.ts +8 -13
  54. package/dist/todo/store.js +13 -14
  55. package/dist/todo/types.d.ts +2 -1
  56. package/dist/ui/App.d.ts +2 -0
  57. package/dist/ui/App.js +233 -183
  58. package/dist/ui/AutocompletePopup.d.ts +3 -1
  59. package/dist/ui/AutocompletePopup.js +9 -11
  60. package/dist/ui/BottomStatusRow.d.ts +1 -1
  61. package/dist/ui/Composer.d.ts +13 -5
  62. package/dist/ui/Composer.js +7 -11
  63. package/dist/ui/PromptCaret.d.ts +11 -0
  64. package/dist/ui/PromptCaret.js +31 -0
  65. package/dist/ui/PromptInput.d.ts +12 -6
  66. package/dist/ui/PromptInput.js +50 -16
  67. package/dist/ui/StatusBar.d.ts +22 -15
  68. package/dist/ui/StatusBar.js +61 -16
  69. package/dist/ui/TeamPanel.d.ts +3 -1
  70. package/dist/ui/TeamPanel.js +13 -9
  71. package/dist/ui/TodoPanel.d.ts +5 -5
  72. package/dist/ui/TodoPanel.js +56 -24
  73. package/dist/ui/Transcript.js +1 -0
  74. package/dist/ui/UpdateLine.d.ts +1 -1
  75. package/dist/ui/capabilities.js +5 -5
  76. package/dist/ui/composer-rows.js +50 -9
  77. package/dist/ui/exit-snapshot.d.ts +9 -0
  78. package/dist/ui/frame-differ.js +2 -19
  79. package/dist/ui/frame-observer.d.ts +17 -0
  80. package/dist/ui/frame-observer.js +81 -0
  81. package/dist/ui/frame-parser.d.ts +7 -0
  82. package/dist/ui/frame-parser.js +18 -0
  83. package/dist/ui/layout/AppShell.d.ts +7 -52
  84. package/dist/ui/layout/AppShell.js +2 -2
  85. package/dist/ui/layout/ScrollIndicator.d.ts +4 -3
  86. package/dist/ui/layout/ScrollIndicator.js +4 -4
  87. package/dist/ui/layout/ScrollViewport.d.ts +13 -66
  88. package/dist/ui/layout/ScrollViewport.js +183 -185
  89. package/dist/ui/layout/autocomplete.d.ts +18 -0
  90. package/dist/ui/layout/autocomplete.js +26 -0
  91. package/dist/ui/layout/budget.d.ts +3 -51
  92. package/dist/ui/layout/budget.js +4 -53
  93. package/dist/ui/layout/document-layout.d.ts +13 -0
  94. package/dist/ui/layout/document-layout.js +11 -0
  95. package/dist/ui/layout/follow-state.d.ts +1 -0
  96. package/dist/ui/layout/follow-state.js +1 -3
  97. package/dist/ui/layout/rail.d.ts +4 -53
  98. package/dist/ui/layout/rail.js +10 -62
  99. package/dist/ui/layout/scroll-indicator.d.ts +1 -1
  100. package/dist/ui/layout/scroll-indicator.js +3 -2
  101. package/dist/ui/layout/scrollbar-geometry.d.ts +17 -0
  102. package/dist/ui/layout/scrollbar-geometry.js +13 -0
  103. package/dist/ui/layout/team-panel.d.ts +18 -0
  104. package/dist/ui/layout/team-panel.js +22 -0
  105. package/dist/ui/layout/todo-layout.d.ts +25 -0
  106. package/dist/ui/layout/todo-layout.js +28 -0
  107. package/dist/ui/layout/viewport-geometry.d.ts +1 -0
  108. package/dist/ui/layout/viewport-geometry.js +1 -0
  109. package/dist/ui/layout/virtual-window.d.ts +1 -0
  110. package/dist/ui/layout/virtual-window.js +1 -1
  111. package/dist/ui/overlays/HelpOverlay.js +5 -2
  112. package/dist/ui/screen.d.ts +1 -1
  113. package/dist/ui/screen.js +4 -6
  114. package/dist/ui/scrollbar-controller.d.ts +30 -0
  115. package/dist/ui/scrollbar-controller.js +124 -0
  116. package/dist/ui/transcript-text.js +6 -2
  117. package/dist/ui/use-wheel-routing.d.ts +2 -0
  118. package/dist/ui/use-wheel-routing.js +6 -0
  119. package/package.json +2 -2
@@ -1,7 +1,8 @@
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
- import { formatCost, formatTokens, formatDuration } from '../agent/usage.js';
5
+ import { formatCost, formatTokens, formatDuration, promptTokensOf } from '../agent/usage.js';
5
6
  import { buildGauge } from './gauge.js';
6
7
  import { MODE_LABEL } from '../agent/agent-mode.js';
7
8
  import { formatRetryChip } from '../agent/retry-view.js';
@@ -12,6 +13,26 @@ import { TODO_LIMITS } from '../todo/limits.js';
12
13
  import { PROC_LIMITS } from '../proc/limits.js';
13
14
  /** Below this the `eco` chip costs the context gauge more than it is worth. */
14
15
  const ECO_MIN_COLS = 72;
16
+ /**
17
+ * Where `86k/200k` starts being shown (context-usage-gauge-accuracy §3.6 / W5).
18
+ *
19
+ * IT TAKES THE COLUMNS THE SESSION TOTAL USED TO HAVE, and the ordering is the
20
+ * point rather than a layout accident (D-6). "How full am I, out of how much" is
21
+ * the question a user asks the gauge; "what has this session cost in tokens" is
22
+ * a different one, and putting the second in front of the first spends the
23
+ * scarce columns on the number the user is more likely to MISREAD - the total
24
+ * looks like an occupancy until you notice it exceeds the window.
25
+ */
26
+ const ABSOLUTE_PAIR_MIN_COLS = 72;
27
+ /**
28
+ * Where the session total returns, now prefixed.
29
+ *
30
+ * `total ` IS AN ASCII WORD, NOT A SIGMA. `ui/**` is inside the glyph scanner's
31
+ * scope, so a new symbol would need an entry in `glyphs.ts` with an ASCII
32
+ * fallback for a legacy `cmd.exe` - a real cost for a three-character prefix
33
+ * that reads worse.
34
+ */
35
+ const SESSION_TOTAL_MIN_COLS = 96;
15
36
  /**
16
37
  * I-5 — DO NOT "CLEAN THIS UP". Ink short-circuits identical output at TWO
17
38
  * gates (`ink.js:132` and `log-update.js:13`), so a fixed frame whose React
@@ -31,23 +52,42 @@ function redrawChar(nonce) {
31
52
  return nonce % 2 === 0 ? ' ' : NBSP;
32
53
  }
33
54
  export function StatusBar(props) {
34
- const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, todoActive, servicesActive, retryActive, ecoRung = 0, fastActive, compactionActive, gaugeMarks, contextEstimated = false, } = props;
55
+ const { model, provider, usageTotal, context, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, todoActive, servicesActive, retryActive, ecoRung = 0, fastActive, compactionActive, gaugeMarks, } = props;
35
56
  const { stdout } = useStdout();
36
57
  const cols = stdout?.columns ?? 80;
37
58
  const running = status === 'running';
38
59
  const glyphs = pickGlyphs(caps);
39
- const pct = contextWindow > 0 ? Math.min(100, Math.round((contextTokens / contextWindow) * 100)) : 0;
60
+ // THE PERCENTAGE IS NOT RECOMPUTED HERE ANY MORE. It arrives already divided
61
+ // and clamped by the same code the compaction trigger reads, which is the
62
+ // whole of AC-2: a user watching 78 % and a `/compact status` reporting 91 %
63
+ // would rightly conclude one of them is lying.
64
+ const pct = context.pct;
40
65
  // `gaugeMarks` is spread rather than passed as `undefined`, so a session with
41
66
  // compaction off takes `buildGauge`'s own default and the bar is byte-identical
42
67
  // to the pre-feature build.
43
68
  const gauge = gaugeMarks
44
69
  ? buildGauge(pct, cols < 72 ? 8 : 12, theme, caps, gaugeMarks)
45
70
  : buildGauge(pct, cols < 72 ? 8 : 12, theme, caps);
46
- // ONE `~` FOR TWO KINDS OF APPROXIMATION (§6.2). A second marker would be a
47
- // second thing to explain for a distinction the user does not act on
48
- // differently: either way the number is a guess.
49
- const pctLabel = `${contextWindowKnown && !contextEstimated ? '' : '~'}${gauge.pct}%`;
50
- const tokens = `${formatTokens(usageTotal.inputTokens)}${glyphs.arrowUp} ${formatTokens(usageTotal.outputTokens)}${glyphs.arrowDown}`;
71
+ // `~` BELONGS TO THE NUMERATOR, `?` TO THE DENOMINATOR (P2-7). One glyph used
72
+ // to carry three meanings - the window is a guess, the occupancy is a guess,
73
+ // the occupancy contains an appended-message estimate - and a user could not
74
+ // tell from it which half to distrust. The `~` keeps every case it had: an
75
+ // unknown window still produces it, so a session that never touches the new
76
+ // absolute pair sees the identical string it saw before.
77
+ const occupancyApproximate = context.source === 'estimate' || context.deltaTokens > 0;
78
+ const pctLabel = `${context.windowKnown && !occupancyApproximate ? '' : '~'}${gauge.pct}%`;
79
+ // `?` ON THE WINDOW, AND ONLY ON THE NEW READOUT. `windowKnown` is false
80
+ // exactly when `buildRuntimeModel` invented the 128k placeholder, and saying
81
+ // `128k?` is the difference between a denominator the user can act on and one
82
+ // they will assume came from their model card.
83
+ const windowLabel = `${formatTokens(context.window)}${context.windowKnown ? '' : '?'}`;
84
+ const absolutePair = `${formatTokens(context.occupied)}/${windowLabel}`;
85
+ // THE PROMPT-SIDE TOTAL, NOT `inputTokens` (P1-3 / AC-11). Anthropic reports
86
+ // `input_tokens` EXCLUDING cached tokens, so the old expression under-reported
87
+ // for anyone behind a caching gateway - on the same row as a `$` that has
88
+ // always priced the cache. Any session with cache hits sees this number grow,
89
+ // and that is the fix rather than a regression.
90
+ const tokens = `total ${formatTokens(promptTokensOf(usageTotal))}${glyphs.arrowUp} ${formatTokens(usageTotal.outputTokens)}${glyphs.arrowDown}`;
51
91
  const statusGlyph = running ? theme.symbols.toolRunning : theme.symbols.toolPending;
52
92
  // Nothing at all in build mode with nothing pending: the default posture is
53
93
  // not news, and a permanent word here would be furniture.
@@ -56,6 +96,11 @@ export function StatusBar(props) {
56
96
  : pendingAgentMode
57
97
  ? `${MODE_LABEL[agentMode]} ${glyphs.arrowRight} ${MODE_LABEL[pendingAgentMode]}`
58
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);
59
104
  return (
60
105
  // `flexShrink={0}` on the left cluster is not cosmetic. When the two clusters
61
106
  // over-subscribe the row, yoga shrinks the flexible children and Ink's text
@@ -63,11 +108,11 @@ export function StatusBar(props) {
63
108
  // and "? hel" rather than truncating cleanly at one end. Pinning the left
64
109
  // cluster keeps the run status and model intact and makes the degradation
65
110
  // land in one predictable place.
66
- _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
67
- ? `agents ${teamActive.running}/${teamActive.total}`
68
- : `[${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
69
- ? `todo ${todoActive.done}/${todoActive.total}`
70
- : `[${todoActive.done}/${todoActive.total}]`] })), servicesActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= PROC_LIMITS.statusCompactCols
71
- ? `svc ${servicesActive.live}`
72
- : `[${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 >= 72 && _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)] }))] })] })] }));
73
118
  }
@@ -19,6 +19,7 @@
19
19
  import React from 'react';
20
20
  import type { Theme } from './theme.js';
21
21
  import type { TermCapabilities } from './capabilities.js';
22
+ import { type TeamPanelLayout } from './layout/team-panel.js';
22
23
  import type { SubagentRun, TeamSnapshot } from '../team/types.js';
23
24
  export interface TeamPanelProps {
24
25
  snapshot: TeamSnapshot;
@@ -35,6 +36,7 @@ export interface TeamPanelProps {
35
36
  caps: TermCapabilities;
36
37
  /** Injectable clock so the panel test does not depend on wall time. */
37
38
  now?: number;
39
+ layout?: TeamPanelLayout;
38
40
  }
39
41
  export declare function activityBudget(cols: number): number;
40
42
  /**
@@ -55,4 +57,4 @@ export declare function activityBudget(cols: number): number;
55
57
  * columns and break at 80.
56
58
  */
57
59
  export declare function activityLine(run: SubagentRun, cols: number): string;
58
- export declare function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, now, }: TeamPanelProps): React.ReactElement;
60
+ export declare function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, now, layout, }: TeamPanelProps): React.ReactElement | null;
@@ -5,7 +5,7 @@ import { pickGlyphs } from './glyphs.js';
5
5
  import { formatDuration } from '../agent/usage.js';
6
6
  import { describeToolActivity, sanitizeActivity } from '../team/activity.js';
7
7
  import { TEAM_LIMITS } from '../team/limits.js';
8
- import { selectPanelRows } from '../team/panel-rows.js';
8
+ import { buildTeamPanelLayout } from './layout/team-panel.js';
9
9
  function isRunning(run) {
10
10
  return run.phase === 'starting' || run.phase === 'thinking' || run.phase === 'tool' ||
11
11
  run.phase === 'waiting';
@@ -101,24 +101,28 @@ export function activityLine(run, cols) {
101
101
  return 'aborted';
102
102
  }
103
103
  }
104
- export function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, now, }) {
104
+ export function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, now, layout, }) {
105
+ const projection = layout ?? buildTeamPanelLayout({
106
+ snapshot, terminalRows: rows, availableRows: Infinity,
107
+ });
108
+ if (projection.rowCount === 0)
109
+ return null;
105
110
  const glyphs = pickGlyphs(caps);
106
111
  const at = now ?? Date.now();
107
112
  const running = snapshot.runs.filter(isRunning).length;
108
113
  const done = snapshot.runs.filter(isSettled).length;
109
114
  const elapsed = formatDuration(Math.max(0, at - snapshot.startedAt));
110
115
  const header = (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate", color: theme.accent, bold: true, children: [glyphs.teamAgent, " team", ' ', _jsxs(Text, { color: theme.muted, children: [running, " running ", glyphs.midDot, " ", done, " done ", glyphs.midDot, " ", elapsed] })] }), snapshot.messageCount > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.teamMail, " ", snapshot.messageCount] }))] }));
111
- // Below 20 rows the roster is more expensive than the transcript it displaces,
112
- // so the panel degrades to the one line that still answers "is something
113
- // running, and for how long" (§6.4).
114
- if (rows < TEAM_LIMITS.panelCollapseRows) {
116
+ // Short terminals or a long draft leave only the shared header projection,
117
+ // which still answers whether anything is running and for how long.
118
+ if (projection.collapsed) {
115
119
  return _jsx(Box, { flexDirection: "column", flexShrink: 0, children: header });
116
120
  }
117
121
  // RANKED, not sliced by array index (F-2). The slot pool hands out indices in
118
122
  // order, so `slice(0, 5)` showed the five children that finished FIRST and hid
119
123
  // everything still working behind `+N more` - at exactly the fan-out widths
120
124
  // the requirement permits.
121
- const { visible, hiddenTotal, hiddenRunning } = selectPanelRows(snapshot.runs, TEAM_LIMITS.panelMaxRows);
125
+ const { visible, hiddenTotal, hiddenRunning } = projection;
122
126
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [header, visible.map((run) => {
123
127
  const color = statusColor(run, theme);
124
128
  // One spinner per RUNNING row, and the row cap is 5 precisely because
@@ -126,8 +130,8 @@ export function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, no
126
130
  // `reducedMotion` and a non-Unicode terminal fall back to the same
127
131
  // static marker, exactly as `AssistantEntry` and `ToolCard` already do.
128
132
  const animate = isRunning(run) && !reducedMotion && caps.unicode;
129
- return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: color, children: [' ', animate ? _jsx(Spinner, { type: "dots" }) : marker(run, glyphs), ' '] }), _jsx(Text, { color: theme.primary, children: `${run.label}${run.tier === 'fast' ? '~' : ''}`.padEnd(8).slice(0, 8) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: theme.muted, children: run.description }) }), _jsx(Box, { flexShrink: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: color, children: [' ', activityLine(run, cols)] }) }), _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? at) - run.startedAt)) : ''] })] }, run.label));
130
- }), hiddenTotal > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenTotal, " more", hiddenRunning > 0 ? ` (${hiddenRunning} running)` : ''] })), snapshot.lastMessage && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', glyphs.teamMail, " ", snapshot.lastMessage.from, " ", glyphs.arrowRight, " ", snapshot.lastMessage.to, ": ", snapshot.lastMessage.subject] }))] }));
133
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: color, children: [' ', animate ? _jsx(Spinner, { type: "dots" }) : marker(run, glyphs), ' '] }), _jsx(Text, { color: theme.primary, children: `${run.label}${run.tier === 'fast' ? '~' : ''}`.padEnd(8).slice(0, 8) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: theme.muted, children: run.description.replace(/\s+/g, ' ') }) }), _jsx(Box, { flexShrink: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: color, children: [' ', activityLine(run, cols)] }) }), _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? at) - run.startedAt)) : ''] })] }, run.label));
134
+ }), hiddenTotal > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenTotal, " more", hiddenRunning > 0 ? ` (${hiddenRunning} running)` : ''] })), snapshot.lastMessage && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', glyphs.teamMail, " ", snapshot.lastMessage.from, " ", glyphs.arrowRight, " ", snapshot.lastMessage.to, ": ", snapshot.lastMessage.subject.replace(/\s+/g, ' ')] }))] }));
131
135
  }
132
136
  function marker(run, glyphs) {
133
137
  if (run.phase === 'done')
@@ -14,13 +14,13 @@
14
14
  * stop being a rail and start eating viewport height, growing and shrinking the
15
15
  * transcript every time the list changed length.
16
16
  *
17
- * WIDTH IS MEASURED IN COLUMNS, NEVER IN CODE UNITS (P2-3). This rail is 18-36
17
+ * WIDTH IS MEASURED IN COLUMNS, NEVER IN CODE UNITS (P2-3). This rail is 14-36
18
18
  * columns wide and the system prompt's last line is "Respond in the language the
19
19
  * user writes in", so CJK item text in an 18-column rail is the NORMAL case
20
20
  * rather than the edge case. Every text cell is `wrap="truncate"` and Ink's
21
21
  * truncation is display-width aware, so nothing can overflow the box — and NO
22
- * ARITHMETIC HERE MAY BUDGET USER TEXT BY `.length`. The `padEnd(3)` /
23
- * `padStart(2)` cells below are ASCII markers and indices, which is why they are
22
+ * ARITHMETIC HERE MAY BUDGET USER TEXT BY `.length`. The count string and
23
+ * `padStart(2)` cells below are ASCII counts and indices, which is why they are
24
24
  * safe; a "characters that fit" calculation over `content` would not be. That is
25
25
  * the whole of the lesson `TeamPanel`'s `WIDE_CHAR_ALLOWANCE` records at length,
26
26
  * and the mitigation here is to have no such budget at all.
@@ -34,7 +34,7 @@ export interface TodoPanelProps {
34
34
  /** From `todoRailWidth(cols)`. The rail's whole column budget, separator included. */
35
35
  width: number;
36
36
  /**
37
- * From `todoRailRows(viewportBudget, teamActive)` — NOT `viewportBudget`
37
+ * From `buildTodoRailLayout` after actual team/menu rows are subtracted.
38
38
  * (I-10 / P1-3). The gate that mounts this component and the number it lays
39
39
  * out against are ONE value computed once in `App`: a panel that mounts on one
40
40
  * number and lays out against another renders nothing and reports nothing.
@@ -46,4 +46,4 @@ export interface TodoPanelProps {
46
46
  theme: Theme;
47
47
  caps: TermCapabilities;
48
48
  }
49
- export declare function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }: TodoPanelProps): React.ReactElement;
49
+ export declare function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }: TodoPanelProps): React.ReactElement | null;
@@ -1,4 +1,32 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * TodoPanel — the right rail (todo-plan-execution §6.1).
4
+ *
5
+ * ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
6
+ * capability probe and shows mojibake on a legacy console (`glyphs.ts` header).
7
+ *
8
+ * MOUNTED ONLY WHEN A LIST EXISTS. `App` passes `null` the rest of the time and
9
+ * this component is not rendered at all, so a session that never plans has zero
10
+ * columns and zero layout shift (R-g's "no furniture when idle" / AC-24).
11
+ *
12
+ * IT MUST BE MOUNTED IN `AppShell`'s MIDDLE BAND, NEVER IN THE BOTTOM CHROME
13
+ * (C-6 / I-4). This is a COLUMN BESIDE the viewport, and the middle band is the
14
+ * only row-wrapped region there is; moved down into the bottom chrome it would
15
+ * stop being a rail and start eating viewport height, growing and shrinking the
16
+ * transcript every time the list changed length.
17
+ *
18
+ * WIDTH IS MEASURED IN COLUMNS, NEVER IN CODE UNITS (P2-3). This rail is 14-36
19
+ * columns wide and the system prompt's last line is "Respond in the language the
20
+ * user writes in", so CJK item text in an 18-column rail is the NORMAL case
21
+ * rather than the edge case. Every text cell is `wrap="truncate"` and Ink's
22
+ * truncation is display-width aware, so nothing can overflow the box — and NO
23
+ * ARITHMETIC HERE MAY BUDGET USER TEXT BY `.length`. The count string and
24
+ * `padStart(2)` cells below are ASCII counts and indices, which is why they are
25
+ * safe; a "characters that fit" calculation over `content` would not be. That is
26
+ * the whole of the lesson `TeamPanel`'s `WIDE_CHAR_ALLOWANCE` records at length,
27
+ * and the mitigation here is to have no such budget at all.
28
+ */
29
+ import { useMemo } from 'react';
2
30
  import { Box, Text } from 'ink';
3
31
  import Spinner from 'ink-spinner';
4
32
  import { pickGlyphs } from './glyphs.js';
@@ -6,7 +34,7 @@ import { buildGauge } from './gauge.js';
6
34
  import { railBorderProps } from './layout/Gutter.js';
7
35
  import { TODO_RAIL_INDEX_MIN_COLS } from './layout/rail.js';
8
36
  import { TODO_LIMITS } from '../todo/limits.js';
9
- import { selectTodoRows } from '../todo/panel-rows.js';
37
+ import { selectCompactTodoRows, selectTodoRows } from '../todo/panel-rows.js';
10
38
  /** Header row + the blank row under the gauge. */
11
39
  const FIXED_ROWS = 2;
12
40
  /** The gauge is the one purely decorative element, so it is the first to go. */
@@ -19,8 +47,11 @@ const MARKER_COLS = 3;
19
47
  /** Index cell: two digits plus the space that separates it from the text. */
20
48
  const INDEX_COLS = 3;
21
49
  function marker(item, glyphs) {
22
- if (item.status === 'completed')
23
- return glyphs.todoDone;
50
+ // Ink's tokenizer otherwise treats the bare emoji-capable check as one cell,
51
+ // while string-width counts two, adding an extra visible space to each row.
52
+ if (item.status === 'completed') {
53
+ return glyphs.todoDone.length === 1 ? `${glyphs.todoDone}\uFE0F` : glyphs.todoDone;
54
+ }
24
55
  if (item.status === 'in_progress')
25
56
  return glyphs.todoActive;
26
57
  return glyphs.todoPending;
@@ -34,41 +65,42 @@ function rowColor(item, theme) {
34
65
  }
35
66
  export function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }) {
36
67
  const glyphs = pickGlyphs(caps);
68
+ // Ink reapplies all four Yoga borders when the borderStyle object changes,
69
+ // but unchanged borderTop/Bottom=false props are not reapplied on that update.
70
+ const border = useMemo(() => railBorderProps(glyphs.railVertical), [glyphs.railVertical]);
71
+ if (snapshot.items.length === 0 || rows < TODO_LIMITS.panelMinRows)
72
+ return null;
73
+ const compact = rows < TODO_LIMITS.panelFullRows;
37
74
  const showGauge = width >= GAUGE_MIN_COLS && rows >= GAUGE_MIN_ROWS;
38
- const showIndex = width >= TODO_RAIL_INDEX_MIN_COLS;
39
- const itemRows = Math.max(1, rows - FIXED_ROWS - (showGauge ? 1 : 0));
40
- const { visible, hiddenAbove, hiddenBelow } = selectTodoRows(snapshot.items, itemRows);
75
+ const showIndex = !compact && width >= TODO_RAIL_INDEX_MIN_COLS;
76
+ const compactFooter = compact && snapshot.items.length > rows - 1;
77
+ const itemRows = compact ? rows - 1 - Number(compactFooter)
78
+ : rows - FIXED_ROWS - Number(showGauge);
79
+ const { visible, hiddenAbove, hiddenBelow } = compact
80
+ ? selectCompactTodoRows(snapshot.items, itemRows)
81
+ : selectTodoRows(snapshot.items, itemRows);
41
82
  const pct = snapshot.total > 0 ? Math.round((snapshot.doneCount / snapshot.total) * 100) : 0;
42
83
  const gauge = buildGauge(pct, Math.max(4, width - CHROME_COLS - 2), theme, caps);
43
84
  const complete = snapshot.doneCount === snapshot.total;
85
+ const countText = `${snapshot.doneCount}/${snapshot.total}`;
86
+ const showDone = complete
87
+ && width - CHROME_COLS >= 'TODO '.length + countText.length + ' done'.length;
88
+ const count = `${countText}${showDone ? ' done' : ''}`;
44
89
  return (
45
90
  // `flexShrink={0}` IS LOAD-BEARING (P1-4). Ink's `Box.defaultProps` is
46
91
  // `{ flexWrap:'nowrap', flexDirection:'row', flexGrow:0, flexShrink:1 }`, so
47
92
  // a `width={n}` box inside a shrinking row is SQUEEZED under pressure — and
48
93
  // every piece of arithmetic in `rail.ts` silently becomes a suggestion at
49
94
  // exactly the narrow widths where the guarantee matters. `ScrollIndicator`
50
- // spells the same pair one level down. Cross-axis height needs no prop:
51
- // `alignItems` is unset in this tree and yoga's default is `stretch`.
52
- _jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, overflow: "hidden", paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { wrap: "truncate", color: theme.accent, bold: true, children: "TODO" }), _jsxs(Text, { wrap: "truncate", color: complete ? theme.toolDone : theme.muted, children: [snapshot.doneCount, "/", snapshot.total, complete ? ' done' : ''] })] }), showGauge && (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty })] })), _jsx(Text, { children: " " }), hiddenAbove > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenAbove, " above"] })), visible.map(({ item, index }) => {
95
+ // spells the same pair one level down. Height uses the same row budget as
96
+ // selection, so content cannot silently enlarge the middle band.
97
+ _jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, height: rows, overflow: "hidden", paddingLeft: 1, ...border, borderColor: theme.border, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: theme.accent, bold: true, children: "TODO" }) }), _jsx(Box, { width: count.length, flexShrink: 0, children: _jsx(Text, { wrap: "truncate", color: complete ? theme.toolDone : theme.muted, children: count }) })] }), showGauge && (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty })] })), !compact && _jsx(Text, { children: " " }), !compact && hiddenAbove > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenAbove, " above"] })), visible.map(({ item, index }) => {
53
98
  const active = item.status === 'in_progress';
54
99
  // The spinner is gated on `running`: a spinner while the agent is idle
55
100
  // is a lie about the state of the world, and this panel outlives the run
56
101
  // that filled it. Reduced motion and a non-Unicode terminal fall back to
57
102
  // the same static marker, exactly as `AssistantEntry` and `TeamPanel` do.
58
103
  const animate = active && running && !reducedMotion && caps.unicode;
59
- return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: MARKER_COLS, children: _jsx(Text, { color: rowColor(item, theme), bold: active, children: animate ? _jsx(Spinner, { type: "dots" }) : marker(item, glyphs) }) }), showIndex && (_jsx(Box, { flexShrink: 0, width: INDEX_COLS, children: _jsx(Text, { color: theme.muted, children: String(index + 1).padStart(2) }) })), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: active ? 'wrap' : 'truncate', color: rowColor(item, theme), bold: active, children: active ? clampWrapped(item.activeForm, width) : item.content }) })] }, `${index}-${item.content}`));
60
- }), hiddenBelow > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenBelow, " more"] }))] }));
61
- }
62
- /**
63
- * Keep the wrapping row inside `TODO_LIMITS.activeWrapRows`.
64
- *
65
- * A CHARACTER BUDGET IS NOT A COLUMN BUDGET (P2-3), so this is deliberately
66
- * generous rather than exact: it exists to stop a maximum-length `activeForm`
67
- * from claiming five rows of an 18-column rail, and Ink's display-width-aware
68
- * truncation is what actually keeps the row inside the box. Erring long costs
69
- * nothing — the surrounding `overflow="hidden"` clips it.
70
- */
71
- function clampWrapped(text, width) {
72
- const budget = Math.max(1, width - CHROME_COLS) * TODO_LIMITS.activeWrapRows;
73
- return text.length > budget ? text.slice(0, budget) : text;
104
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: MARKER_COLS, children: _jsx(Text, { color: rowColor(item, theme), bold: active, children: animate ? _jsx(Spinner, { type: "dots" }) : marker(item, glyphs) }) }), showIndex && (_jsx(Box, { flexShrink: 0, width: INDEX_COLS, children: _jsx(Text, { color: theme.muted, children: String(index + 1).padStart(2) }) })), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: rowColor(item, theme), bold: active, children: active ? item.activeForm : item.content }) })] }, `${index}-${item.content}`));
105
+ }), compactFooter && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: ["-", hiddenAbove, " +", hiddenBelow] })), !compact && hiddenBelow > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenBelow, " more"] }))] }, glyphs.railVertical));
74
106
  }
@@ -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
  });
@@ -9,7 +9,7 @@
9
9
  * still truthy. Taking that branch would emit a box of ZERO rows - the frame
10
10
  * keeps its fixed height, Yoga hands the row to the transcript, and
11
11
  * `viewportRows()` goes on reporting the old number to `ScrollViewport`,
12
- * `selectWindow`, `overlayMaxRows`, `popupMaxRows` and `todoRailRows`. The
12
+ * `selectWindow`, `overlayMaxRows`, `popupMaxRows` and `buildTodoRailLayout`. The
13
13
  * transcript would gain a row while the updater is idle and lose it the moment
14
14
  * it has news. Presence is the CALLER's decision, through
15
15
  * `shouldRenderUpdateLine`, exactly as `App.tsx` already decides the activity
@@ -15,10 +15,6 @@ function detectColorLevel(env) {
15
15
  return 0;
16
16
  if (env.TERM === 'dumb')
17
17
  return 0;
18
- // A truecolor-capable terminal advertises itself.
19
- const colorterm = env.COLORTERM?.toLowerCase();
20
- if (colorterm === 'truecolor' || colorterm === '24bit')
21
- return 3;
22
18
  // FORCE_COLOR is an explicit override of the auto-detected depth.
23
19
  const force = env.FORCE_COLOR;
24
20
  if (force !== undefined) {
@@ -30,6 +26,10 @@ function detectColorLevel(env) {
30
26
  return 3;
31
27
  return 1; // '1' | 'true' | '' → at least basic color
32
28
  }
29
+ // A truecolor-capable terminal advertises itself.
30
+ const colorterm = env.COLORTERM?.toLowerCase();
31
+ if (colorterm === 'truecolor' || colorterm === '24bit')
32
+ return 3;
33
33
  // Fall back to chalk's own platform detection.
34
34
  const level = chalk.level;
35
35
  return level >= 0 && level <= 3 ? level : 0;
@@ -42,7 +42,7 @@ function detectUnicode(env) {
42
42
  if (env.WT_SESSION)
43
43
  return true; // Windows Terminal
44
44
  const program = env.TERM_PROGRAM;
45
- if (program === 'vscode' || program === 'iTerm.app' || program === 'Apple_Terminal')
45
+ if (program === 'aragonmesh' || program === 'vscode' || program === 'iTerm.app' || program === 'Apple_Terminal')
46
46
  return true;
47
47
  // Legacy cmd.exe (no TERM, no WT_SESSION) and unknown terminals fall back to ASCII.
48
48
  return false;
@@ -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
  }
@@ -14,6 +14,15 @@
14
14
  import type { Entry, UsageTotal } from '../agent/reducer.js';
15
15
  export interface ExitSnapshot {
16
16
  entries: Entry[];
17
+ /**
18
+ * The session total, FORWARDED not copied.
19
+ *
20
+ * It gained `cacheReadTokens` / `cacheWriteTokens`
21
+ * (context-usage-gauge-accuracy §3.6) and this file needed no change for it,
22
+ * which is the point of referencing the reducer's type rather than restating
23
+ * its fields: `renderTranscriptText` reads the total through `promptTokensOf`,
24
+ * so the exit summary and the status bar cannot drift apart.
25
+ */
17
26
  usageTotal: UsageTotal;
18
27
  provider: string;
19
28
  model: string;
@@ -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
+ };