@aragon-agent/cli 0.6.2 → 0.6.3

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 (118) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +202 -0
  3. package/dist/agent/controller.d.ts +74 -1
  4. package/dist/agent/controller.js +200 -1
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +120 -0
  8. package/dist/agent/reducer.js +201 -2
  9. package/dist/cli.js +224 -5
  10. package/dist/commands/builtins.js +73 -23
  11. package/dist/commands/registry.d.ts +27 -0
  12. package/dist/compaction/archive.d.ts +175 -0
  13. package/dist/compaction/archive.js +267 -0
  14. package/dist/compaction/child.d.ts +97 -0
  15. package/dist/compaction/child.js +113 -0
  16. package/dist/compaction/command.d.ts +41 -0
  17. package/dist/compaction/command.js +342 -0
  18. package/dist/compaction/compactor.d.ts +268 -0
  19. package/dist/compaction/compactor.js +689 -0
  20. package/dist/compaction/digest.d.ts +62 -0
  21. package/dist/compaction/digest.js +164 -0
  22. package/dist/compaction/limits.d.ts +165 -0
  23. package/dist/compaction/limits.js +172 -0
  24. package/dist/compaction/pressure.d.ts +135 -0
  25. package/dist/compaction/pressure.js +167 -0
  26. package/dist/compaction/summarize-call.d.ts +70 -0
  27. package/dist/compaction/summarize-call.js +86 -0
  28. package/dist/compaction/summary-prompt.d.ts +85 -0
  29. package/dist/compaction/summary-prompt.js +197 -0
  30. package/dist/compaction/tail-budget.d.ts +52 -0
  31. package/dist/compaction/tail-budget.js +67 -0
  32. package/dist/compaction/types.d.ts +179 -0
  33. package/dist/compaction/types.js +22 -0
  34. package/dist/compaction/wiring.d.ts +255 -0
  35. package/dist/compaction/wiring.js +556 -0
  36. package/dist/config/app-paths.d.ts +14 -0
  37. package/dist/config/app-paths.js +16 -0
  38. package/dist/config/cli-commands.d.ts +41 -0
  39. package/dist/config/cli-commands.js +77 -1
  40. package/dist/config/env.d.ts +4 -1
  41. package/dist/config/env.js +92 -2
  42. package/dist/config/load.d.ts +31 -0
  43. package/dist/config/load.js +63 -1
  44. package/dist/config/schema.d.ts +159 -0
  45. package/dist/config/schema.js +137 -0
  46. package/dist/config/store.js +17 -1
  47. package/dist/config/ui-state.d.ts +28 -1
  48. package/dist/config/ui-state.js +9 -0
  49. package/dist/exec/events.d.ts +50 -1
  50. package/dist/exec/runner.js +42 -0
  51. package/dist/input/mouse-events.d.ts +31 -5
  52. package/dist/input/mouse-events.js +86 -27
  53. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  54. package/dist/logging/install.d.ts +51 -1
  55. package/dist/logging/install.js +28 -0
  56. package/dist/logging/logger.d.ts +1 -1
  57. package/dist/session/persist.js +19 -0
  58. package/dist/team/report.js +7 -1
  59. package/dist/team/runtime.d.ts +10 -0
  60. package/dist/team/runtime.js +7 -0
  61. package/dist/team/subagent.d.ts +31 -1
  62. package/dist/team/subagent.js +42 -15
  63. package/dist/team/types.d.ts +8 -0
  64. package/dist/ui/ActivityLine.d.ts +19 -1
  65. package/dist/ui/ActivityLine.js +10 -2
  66. package/dist/ui/App.d.ts +48 -1
  67. package/dist/ui/App.js +294 -13
  68. package/dist/ui/Composer.d.ts +13 -1
  69. package/dist/ui/Composer.js +2 -2
  70. package/dist/ui/PromptInput.d.ts +34 -1
  71. package/dist/ui/PromptInput.js +54 -3
  72. package/dist/ui/StatusBar.d.ts +40 -0
  73. package/dist/ui/StatusBar.js +13 -4
  74. package/dist/ui/TeamPanel.js +7 -1
  75. package/dist/ui/Transcript.d.ts +21 -1
  76. package/dist/ui/Transcript.js +32 -2
  77. package/dist/ui/clipboard.d.ts +52 -0
  78. package/dist/ui/clipboard.js +91 -0
  79. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  80. package/dist/ui/entries/CompactionCard.js +158 -0
  81. package/dist/ui/frame-differ.d.ts +46 -0
  82. package/dist/ui/frame-differ.js +94 -10
  83. package/dist/ui/gauge.d.ts +16 -1
  84. package/dist/ui/gauge.js +25 -6
  85. package/dist/ui/glyphs.d.ts +11 -0
  86. package/dist/ui/glyphs.js +2 -0
  87. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  88. package/dist/ui/layout/ScrollViewport.js +128 -21
  89. package/dist/ui/layout/budget.d.ts +10 -3
  90. package/dist/ui/layout/budget.js +10 -3
  91. package/dist/ui/layout/follow-state.d.ts +137 -0
  92. package/dist/ui/layout/follow-state.js +150 -0
  93. package/dist/ui/layout/virtual-window.d.ts +11 -0
  94. package/dist/ui/layout/virtual-window.js +34 -0
  95. package/dist/ui/overlays/HelpOverlay.js +6 -1
  96. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  97. package/dist/ui/overlays/SettingsScreen.js +60 -1
  98. package/dist/ui/palettes.d.ts +16 -0
  99. package/dist/ui/palettes.js +6 -0
  100. package/dist/ui/screen.d.ts +22 -0
  101. package/dist/ui/screen.js +70 -8
  102. package/dist/ui/selection/highlight.d.ts +39 -0
  103. package/dist/ui/selection/highlight.js +64 -0
  104. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  105. package/dist/ui/selection/screen-mirror.js +35 -0
  106. package/dist/ui/selection/selection-controller.d.ts +88 -0
  107. package/dist/ui/selection/selection-controller.js +282 -0
  108. package/dist/ui/selection/selection.d.ts +90 -0
  109. package/dist/ui/selection/selection.js +223 -0
  110. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  111. package/dist/ui/stdout-frame-writer.js +15 -0
  112. package/dist/ui/theme.d.ts +11 -0
  113. package/dist/ui/theme.js +2 -0
  114. package/dist/ui/transcript-text.js +23 -0
  115. package/dist/ui/use-startup-notices.d.ts +27 -1
  116. package/dist/ui/use-startup-notices.js +43 -11
  117. package/dist/ui/use-wheel-routing.js +8 -0
  118. package/package.json +4 -2
@@ -0,0 +1,158 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * CompactionCard — one context compaction in the transcript
4
+ * (context-auto-compaction §6.3).
5
+ *
6
+ * ASCII ONLY: every glyph comes from `pickGlyphs`, INCLUDING THE ARROW. The mock
7
+ * in the design shows `112 -> 9 messages`; that arrow is `glyphs.arrowRight`, the
8
+ * one `StatusBar` already uses, not a literal — `ui/` is inside
9
+ * `glyphs.test.ts::inScope`, so a literal here fails the scan (P2-7).
10
+ *
11
+ * EXPANDABLE, UNLIKE `FastCard`. A critique is already clamped to
12
+ * `fast.reviewMaxChars` before it reaches its card, so there is nothing hidden
13
+ * for Ctrl+O to reveal; a summary is up to `summaryMaxChars` and there genuinely
14
+ * is more to see. That is why `App`'s Ctrl+O predicate had to learn about this
15
+ * kind — without it the card renders a hint that does nothing and expands an
16
+ * older tool card instead (C-15 / P1-8 / AC-21).
17
+ *
18
+ * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9). `/clear`
19
+ * empties the transcript view but not `messages`, so this card can legitimately
20
+ * report "112 -> 9 messages" with nine visible rows above it. Nothing here says
21
+ * "above" or "earlier in this transcript".
22
+ */
23
+ import React from 'react';
24
+ import { Box, Text } from 'ink';
25
+ import Spinner from 'ink-spinner';
26
+ import { pickGlyphs } from '../glyphs.js';
27
+ import { railBorderProps } from '../layout/Gutter.js';
28
+ import { formatDuration, formatTokens } from '../../agent/usage.js';
29
+ import { COMPACTION_LIMITS } from '../../compaction/limits.js';
30
+ /**
31
+ * The card's colour.
32
+ *
33
+ * `truncated` is a WARNING rather than the accent, because it is a real and
34
+ * bounded data loss the user is entitled to notice; `applied: false` is an error
35
+ * because the run is still at the occupancy that triggered it.
36
+ */
37
+ export function compactionCardColor(props, theme) {
38
+ if (props.live)
39
+ return theme.toolRunning;
40
+ if (!props.applied)
41
+ return theme.toolError;
42
+ if (props.mode === 'truncated' || props.mode === 'relieved')
43
+ return theme.noticeWarn;
44
+ return theme.accent;
45
+ }
46
+ /** The one-line summary of what the compaction did, or why it did not. */
47
+ function headline(props, arrow, dot) {
48
+ const { index, live } = props;
49
+ // The model is `''` when no summarizer resolves (no key for the provider), and
50
+ // the separator has to go with it — a headline ending in a dangling ` - `
51
+ // reads as a rendering fault rather than as the missing model it is.
52
+ if (live) {
53
+ // ELAPSED IN THE SAME POSITION `durationMs` TAKES ONCE SETTLED, so the number
54
+ // does not jump across the line when the card settles. Between second 0 and
55
+ // second 45 this was the only changing information on the card, and there was
56
+ // none.
57
+ const elapsed = props.elapsedMs === undefined ? '' : `${dot}${formatDuration(props.elapsedMs)}`;
58
+ return props.model
59
+ ? `compacting context #${index}${dot}${props.model}${elapsed}`
60
+ : `compacting context #${index}${elapsed}`;
61
+ }
62
+ if (!props.applied) {
63
+ const why = props.reason ? `: ${props.reason}` : '';
64
+ return `context not compacted #${index}${why}`;
65
+ }
66
+ const messages = `${props.messagesBefore} ${arrow} ${props.messagesAfter} messages`;
67
+ const tokens = `${formatTokens(props.tokensBefore)} ${arrow} ${formatTokens(props.tokensAfter)} tokens`;
68
+ const duration = props.durationMs === undefined ? '' : `${dot}${formatDuration(props.durationMs)}`;
69
+ return `context compacted${dot}${messages}${dot}${tokens}${duration}`;
70
+ }
71
+ /**
72
+ * The muted second line: how it was done, and with what.
73
+ *
74
+ * THE LIVE BRANCH NAMES THE ESCAPE (§3.6 / E-10). `AgentController.abort()` has
75
+ * always called `this.compaction?.abort()` before `agent.abort()`, so Esc has
76
+ * always cancelled an in-flight summarization - and no surface has ever said so
77
+ * during the forty-five seconds it matters. The hint goes with the model for the
78
+ * same reason the separator does: with no summarizer resolved there is no call to
79
+ * cancel.
80
+ *
81
+ * THE `relieved` BRANCH COMES BEFORE `truncated`, AND IT EXISTS BECAUSE THE
82
+ * `summarized` LINE WOULD OTHERWISE LIE. Relief drops no messages, so the
83
+ * fall-through would render "summarized 0 messages with <model>" for a
84
+ * compaction in which no model was called - in the rare, alarming case the user
85
+ * most needs to read correctly.
86
+ */
87
+ function detailLine(props) {
88
+ if (props.live) {
89
+ if (!props.model)
90
+ return '';
91
+ return `summarizing with ${props.model} - esc to cancel`;
92
+ }
93
+ if (!props.applied)
94
+ return props.mode === 'none' && !props.reason ? 'nothing was changed' : '';
95
+ const dropped = Math.max(0, props.messagesBefore - props.messagesAfter);
96
+ if (props.mode === 'relieved') {
97
+ const n = props.tailRelief?.messages ?? 0;
98
+ return `clipped ${n} tool ${n === 1 ? 'result' : 'results'} in the retained turns - nothing could be dropped`;
99
+ }
100
+ if (props.mode === 'truncated') {
101
+ return `dropped ${dropped} messages WITHOUT a summary - summarization failed`;
102
+ }
103
+ const via = props.trigger === 'overflow' ? ' after a context overflow' : '';
104
+ return `summarized ${dropped} messages with ${props.model}${via}`;
105
+ }
106
+ /**
107
+ * The relief disclosure, on a settled card that ALSO summarized.
108
+ *
109
+ * A BOUNDED, ANNOUNCED DATA LOSS THE USER IS ENTITLED TO NOTICE, in the same
110
+ * voice §6.4's honesty rules use for `truncated`. Suppressed on the `relieved`
111
+ * card, where `detailLine` has already said it and a second line would be the
112
+ * same sentence twice.
113
+ */
114
+ function reliefLine(props) {
115
+ if (props.live || !props.applied || !props.tailRelief || props.mode === 'relieved')
116
+ return '';
117
+ const n = props.tailRelief.messages;
118
+ return (`clipped ${n} tool ${n === 1 ? 'result' : 'results'} in the retained turns ` +
119
+ `(${formatTokens(props.tailRelief.charsRemoved)} characters) - ` +
120
+ 'the recent turns alone exceeded the window');
121
+ }
122
+ /**
123
+ * Split the summary into rows.
124
+ *
125
+ * A ROW budget, not a character one: `summaryMaxChars` already bounded the text,
126
+ * and this only decides how much of it the COLLAPSED card shows. Ink wraps the
127
+ * rest.
128
+ */
129
+ function summaryRows(text, expanded) {
130
+ const lines = text.split('\n').filter((l) => l.trim().length > 0);
131
+ return expanded ? lines : lines.slice(0, COMPACTION_LIMITS.cardTextRows);
132
+ }
133
+ function CompactionCardImpl(props) {
134
+ const { live, summary, expanded, reducedMotion, theme, caps } = props;
135
+ const glyphs = pickGlyphs(caps);
136
+ const color = compactionCardColor(props, theme);
137
+ const dot = ` ${glyphs.midDot} `;
138
+ const head = headline(props, glyphs.arrowRight, dot);
139
+ const detail = detailLine(props);
140
+ const relief = reliefLine(props);
141
+ const rows = summary ? summaryRows(summary, expanded === true) : [];
142
+ const totalRows = summary ? summary.split('\n').filter((l) => l.trim().length > 0).length : 0;
143
+ const hidden = Math.max(0, totalRows - rows.length);
144
+ // COLOUR TRACKS `live`, MOTION TRACKS `animate` — one headline, two separate
145
+ // decisions, and the split is the one `FastCard` records at length: `App`
146
+ // widens `reducedMotion` to "this frame does not animate here" for the whole of
147
+ // every run, and folding the two together would grey out every live card for
148
+ // the whole run, losing the status colour along with the motion.
149
+ const animate = live && reducedMotion !== true && caps.unicode;
150
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "compact" }), _jsxs(Text, { color: live ? color : theme.muted, wrap: "truncate", children: [' ', animate ? (_jsxs(_Fragment, { children: [_jsx(Spinner, { type: "dots" }), ' '] })) : live ? (`${glyphs.spinnerStill} `) : (''), head] })] }), detail.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: color, wrap: "truncate", children: detail }) })), relief.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, children: _jsx(Text, { color: theme.noticeWarn, children: relief }) })), rows.length > 0 && (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [rows.map((line, index) => (_jsx(Text, { color: theme.primary, children: line }, index))), hidden > 0 && (_jsxs(Text, { color: theme.muted, children: [glyphs.railEnd, " +", hidden, " more lines (ctrl+o to expand)"] })), hidden === 0 && expanded === true && totalRows > COMPACTION_LIMITS.cardTextRows && (_jsxs(Text, { color: theme.muted, children: [glyphs.railEnd, " ctrl+o to collapse"] }))] }))] }));
151
+ }
152
+ /**
153
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
154
+ * `mapEntry` already preserves object identity for untouched entries, so the
155
+ * props below are stable references on a settled card, and `theme` / `caps` are
156
+ * `useMemo`d in `App.tsx` (I-L2-1).
157
+ */
158
+ export const CompactionCard = React.memo(CompactionCardImpl);
@@ -58,6 +58,31 @@ export interface FrameDifferOptions {
58
58
  rows: () => number | undefined;
59
59
  /** Raised once, on the 0→1 edge of `fallbacks` (§5.6). */
60
60
  onFirstFallback?: () => void;
61
+ /**
62
+ * Raw frame lines in, lines to PAINT out. Identity when absent, and absent is
63
+ * the default — a session without drag-select never allocates a second array.
64
+ *
65
+ * Called AFTER the geometry stand-down and BEFORE the cache comparison, so a
66
+ * frame this module refuses to address never reaches the selection layer, and
67
+ * the diff below still compares what is actually on the screen (I-2).
68
+ *
69
+ * It MUST return the same number of lines it was given; a decorator that
70
+ * changes the count would desynchronise absolute addressing, so the result is
71
+ * discarded if it does.
72
+ */
73
+ decorate?: (lines: string[]) => string[];
74
+ /**
75
+ * Raised whenever the cache is dropped — a foreign write, a resize, a geometry
76
+ * stand-down, an explicit `invalidate()`.
77
+ *
78
+ * P1-7: on every one of those paths the RAW Ink frame reaches the screen
79
+ * verbatim, so any highlight is wiped off the terminal while the selection
80
+ * controller still believes it is painted and its mirror still holds the rows
81
+ * of the last DECORATED frame. A release would then copy text that is not on
82
+ * screen. I-4's "by construction" argument has exactly this hole, and one
83
+ * callback closes it: the controller clears the selection.
84
+ */
85
+ onInvalidate?: () => void;
61
86
  }
62
87
  export interface FrameWriterStats {
63
88
  /** Frames the differ EMITTED — `framesDiffed + framesFull`, never pass-throughs. */
@@ -78,6 +103,15 @@ export interface FrameWriterStats {
78
103
  * to stdout behind Ink's back" and is what raises the §5.6 notice.
79
104
  */
80
105
  fallbacks: number;
106
+ /**
107
+ * Self-initiated repaints — `repaint()` calls that emitted at least one row.
108
+ *
109
+ * A COUNTER OF ITS OWN, deliberately kept out of `framesTotal` /
110
+ * `framesDiffed` (P2-6). Those count frames INK PRODUCED, and folding a drag's
111
+ * highlight repaints into them would make `/perf`'s frame rate a function of
112
+ * how much the user moved the mouse.
113
+ */
114
+ repaints: number;
81
115
  }
82
116
  export interface FrameDiffer {
83
117
  /**
@@ -91,5 +125,17 @@ export interface FrameDiffer {
91
125
  invalidate(): void;
92
126
  /** Single owner of the counters (P2-2); the writer re-exports this. */
93
127
  stats(): FrameWriterStats;
128
+ /**
129
+ * Re-run `decorate` over the LAST RAW FRAME and emit only the rows whose paint
130
+ * changed; `''` when there is nothing safe to do.
131
+ *
132
+ * This is what makes a drag cost 1-3 addressed rows instead of a React commit
133
+ * and a whole-tree Yoga layout (D-6). `''` is a REAL state rather than an error
134
+ * — after an `invalidate()` there is no cache to diff against and no origin to
135
+ * address from — and the caller's fallback is `App`'s existing `redrawNonce`:
136
+ * ask for a React redraw, the next commit runs `decorate`, and the highlight
137
+ * appears one frame later instead of never.
138
+ */
139
+ repaint(): string;
94
140
  }
95
141
  export declare function createFrameDiffer(options: FrameDifferOptions): FrameDiffer;
@@ -92,8 +92,23 @@ function countErases(prefix) {
92
92
  return prefix.split(ERASE_LINE).length - 1;
93
93
  }
94
94
  export function createFrameDiffer(options) {
95
- /** Last frame, one entry per row. `null` = no cache, next frame is a full repaint. */
95
+ /**
96
+ * Last frame AS PAINTED — what is actually on the screen.
97
+ *
98
+ * `null` = no cache, next frame is a full repaint. THE DIFF COMPARES PAINTED
99
+ * LINES (I-2): diffing raw against a painted screen would leave the highlight
100
+ * burned into rows the next frame never repaints, because those rows' RAW text
101
+ * did not change.
102
+ */
96
103
  let prev = null;
104
+ /**
105
+ * The same frame UNDECORATED — `repaint()`'s input.
106
+ *
107
+ * Two caches rather than one, because a repaint has to re-run `decorate` over
108
+ * the ORIGINAL rows: painting an already-painted row would nest the selection
109
+ * escapes and the highlight would never come off.
110
+ */
111
+ let rawPrev = null;
97
112
  /**
98
113
  * Whether any chunk has been seen at all.
99
114
  *
@@ -111,9 +126,37 @@ export function createFrameDiffer(options) {
111
126
  linesWritten: 0,
112
127
  bytesWritten: 0,
113
128
  fallbacks: 0,
129
+ repaints: 0,
114
130
  };
131
+ /**
132
+ * Re-entrancy guard for `onInvalidate`. The listener clears the selection,
133
+ * which asks for a repaint, which can reach `invalidate()` again; one flag is
134
+ * cheaper than reasoning about how deep that can go.
135
+ */
136
+ let notifying = false;
115
137
  const invalidate = () => {
116
138
  prev = null;
139
+ rawPrev = null;
140
+ if (notifying || !options.onInvalidate)
141
+ return;
142
+ notifying = true;
143
+ try {
144
+ options.onInvalidate();
145
+ }
146
+ finally {
147
+ notifying = false;
148
+ }
149
+ };
150
+ /**
151
+ * `decorate`, with the line-count contract enforced. A decorator that returned
152
+ * a different number of rows would shift every absolute address below it for
153
+ * the rest of the session, so its answer is discarded rather than trusted.
154
+ */
155
+ const applyDecorate = (lines) => {
156
+ if (!options.decorate)
157
+ return lines;
158
+ const painted = options.decorate(lines);
159
+ return painted.length === lines.length ? painted : lines;
117
160
  };
118
161
  /**
119
162
  * Write `chunk` verbatim — today's behaviour, and the only failure mode.
@@ -132,8 +175,9 @@ export function createFrameDiffer(options) {
132
175
  return null;
133
176
  };
134
177
  const wrapSync = (payload) => options.sync ? SYNC_BEGIN + payload + SYNC_END : payload;
135
- const emit = (payload, lines, addressed, full) => {
178
+ const emit = (payload, lines, raw, addressed, full) => {
136
179
  prev = lines;
180
+ rawPrev = raw;
137
181
  counters.framesTotal += 1;
138
182
  if (full)
139
183
  counters.framesFull += 1;
@@ -175,16 +219,16 @@ export function createFrameDiffer(options) {
175
219
  * is no unchanged content that has to survive, and full repaints are rare by
176
220
  * construction — one per session, one per resize, one per invalidation.
177
221
  */
178
- const fullRepaint = (lines) => {
222
+ const fullRepaint = (lines, raw) => {
179
223
  const payload = cursorTo(1) +
180
224
  SGR_RESET +
181
225
  ERASE_DOWN +
182
226
  lines.join('\r\n') +
183
227
  '\r\n' +
184
228
  cursorTo(lines.length + 1);
185
- return emit(wrapSync(payload), lines, lines.length, true);
229
+ return emit(wrapSync(payload), lines, raw, lines.length, true);
186
230
  };
187
- const diffRepaint = (lines, previous) => {
231
+ const diffRepaint = (lines, raw, previous) => {
188
232
  const out = [];
189
233
  for (let i = 0; i < lines.length; i += 1) {
190
234
  // I-4: paint, THEN clear the tail. Never `ERASE_LINE` first.
@@ -196,10 +240,45 @@ export function createFrameDiffer(options) {
196
240
  // on an identical frame, so a chunk that reaches here differs by at least one
197
241
  // line; the only way to observe zero is a cache that disagrees with the
198
242
  // screen, which is the state `invalidate()` exists to prevent.
243
+ //
244
+ // With a `decorate` hook it stops being unreachable in one direction and
245
+ // stays correct: a frame whose RAW rows changed only where the highlight
246
+ // already covers them paints identically, and writing nothing is right.
199
247
  if (out.length === 0)
200
- return emit('', lines, 0, false);
248
+ return emit('', lines, raw, 0, false);
201
249
  out.push(cursorTo(lines.length + 1)); // I-5
202
- return emit(wrapSync(out.join('')), lines, out.length - 1, false);
250
+ return emit(wrapSync(out.join('')), lines, raw, out.length - 1, false);
251
+ };
252
+ /**
253
+ * Re-paint the last frame's rows through `decorate` again, emitting only what
254
+ * changed. Called when the SELECTION moved rather than when Ink produced a
255
+ * frame — hence its own counter and no touch of the frame ones (P2-6).
256
+ *
257
+ * It carries the same DEC 2026 sync envelope and parks on row `H + 1` as every
258
+ * other batch, because it is addressed exactly like a diff frame and a
259
+ * half-applied highlight tears the same way a half-applied frame does.
260
+ */
261
+ const repaint = () => {
262
+ if (prev === null || rawPrev === null)
263
+ return '';
264
+ const rows = usableRows();
265
+ if (rows === undefined)
266
+ return ''; // I-9 — geometry we cannot address safely
267
+ if (rawPrev.length + 1 > rows)
268
+ return '';
269
+ const painted = applyDecorate(rawPrev);
270
+ const out = [];
271
+ for (let i = 0; i < painted.length; i += 1) {
272
+ if (painted[i] !== prev[i]) {
273
+ out.push(cursorTo(i + 1) + SGR_RESET + painted[i] + SGR_RESET + ERASE_TO_EOL);
274
+ }
275
+ }
276
+ prev = painted;
277
+ if (out.length === 0)
278
+ return '';
279
+ out.push(cursorTo(painted.length + 1)); // I-5
280
+ counters.repaints += 1;
281
+ return wrapSync(out.join(''));
203
282
  };
204
283
  const transform = (chunk) => {
205
284
  const firstChunk = !seenAnyChunk;
@@ -217,14 +296,19 @@ export function createFrameDiffer(options) {
217
296
  const lines = body.slice(0, -1).split('\n');
218
297
  if (lines.length + 1 > rows)
219
298
  return passThrough(false); // I-9
220
- if (prev === null || prev.length !== lines.length || matched.erased !== prev.length + 1) {
221
- return fullRepaint(lines);
299
+ // AFTER the geometry stand-down, BEFORE the cache comparison: a frame this
300
+ // module refuses to address never reaches the selection layer, and what is
301
+ // compared below is what the screen will hold.
302
+ const painted = applyDecorate(lines);
303
+ if (prev === null || prev.length !== painted.length || matched.erased !== prev.length + 1) {
304
+ return fullRepaint(painted, lines);
222
305
  }
223
- return diffRepaint(lines, prev);
306
+ return diffRepaint(painted, lines, prev);
224
307
  };
225
308
  return {
226
309
  transform,
227
310
  invalidate,
311
+ repaint,
228
312
  stats: () => ({ ...counters }),
229
313
  };
230
314
  }
@@ -17,8 +17,23 @@ export interface Gauge {
17
17
  /** Clamped percentage 0–100. */
18
18
  pct: number;
19
19
  }
20
+ /**
21
+ * Where the bar changes colour, as percentages.
22
+ *
23
+ * The defaults are the values this function hardcoded before context compaction
24
+ * existed, so every existing call site and test is byte-identical (§6.2).
25
+ */
26
+ export interface GaugeMarks {
27
+ /** Amber at or above this. */
28
+ warn: number;
29
+ /** Red strictly above this. */
30
+ high: number;
31
+ }
20
32
  /**
21
33
  * Build a `width`-cell gauge for `pct` (0–100). Uses block glyphs on Unicode
22
34
  * terminals and `#`/`-` otherwise (e.g. `[######----]`).
35
+ *
36
+ * `marks` is OPTIONAL and defaults to today's values, so omitting it produces
37
+ * byte-identical output to the pre-compaction build.
23
38
  */
24
- export declare function buildGauge(pct: number, width: number, theme: Theme, caps: TermCapabilities): Gauge;
39
+ export declare function buildGauge(pct: number, width: number, theme: Theme, caps: TermCapabilities, marks?: GaugeMarks): Gauge;
package/dist/ui/gauge.js CHANGED
@@ -4,19 +4,38 @@
4
4
  * has no Unicode. Kept out of the `.tsx` so it is unit-testable without Ink.
5
5
  */
6
6
  import { pickGlyphs } from './glyphs.js';
7
- /** Pick the fill colour by threshold: <60 low · 60–85 mid · >85 high. */
8
- function thresholdColor(pct, theme) {
9
- if (pct > 85)
7
+ const DEFAULT_MARKS = { warn: 60, high: 85 };
8
+ /**
9
+ * Pick the fill colour by threshold: `< warn` low · `>= warn` mid · `> high` high.
10
+ *
11
+ * THE TWO OPERATORS ARE PRESERVED EXACTLY (`> high`, `>= warn`), and that is not
12
+ * cosmetic: changing either would move the default 85 or 60 boundary and break
13
+ * the byte-identity claim that makes the `marks` parameter a free change.
14
+ *
15
+ * WHAT RED MEANS, PRECISELY (P2-4). The compaction trigger is
16
+ * `ratio >= threshold` (INCLUSIVE) while this fill is `pct > high` (EXCLUSIVE),
17
+ * so with `high = threshold` any occupancy that would render red has ALREADY
18
+ * triggered compaction, and the gauge repaints downward in the same frame. The
19
+ * red band is therefore only ever OBSERVABLE when compaction did not or could
20
+ * not run — the feature is off, it self-disabled, or the failure ladder ended at
21
+ * its last rung. That is a genuinely more useful meaning than "imminent", and it
22
+ * is the one the README and the settings screen state.
23
+ */
24
+ function thresholdColor(pct, theme, marks) {
25
+ if (pct > marks.high)
10
26
  return theme.gauge.high;
11
- if (pct >= 60)
27
+ if (pct >= marks.warn)
12
28
  return theme.gauge.mid;
13
29
  return theme.gauge.low;
14
30
  }
15
31
  /**
16
32
  * Build a `width`-cell gauge for `pct` (0–100). Uses block glyphs on Unicode
17
33
  * terminals and `#`/`-` otherwise (e.g. `[######----]`).
34
+ *
35
+ * `marks` is OPTIONAL and defaults to today's values, so omitting it produces
36
+ * byte-identical output to the pre-compaction build.
18
37
  */
19
- export function buildGauge(pct, width, theme, caps) {
38
+ export function buildGauge(pct, width, theme, caps, marks = DEFAULT_MARKS) {
20
39
  const clamped = Math.max(0, Math.min(100, Math.round(pct)));
21
40
  const cells = Math.max(1, width);
22
41
  const filledCells = Math.max(0, Math.min(cells, Math.round((clamped / 100) * cells)));
@@ -29,7 +48,7 @@ export function buildGauge(pct, width, theme, caps) {
29
48
  return {
30
49
  filled: full.repeat(filledCells),
31
50
  empty: empty.repeat(cells - filledCells),
32
- fillColor: thresholdColor(clamped, theme),
51
+ fillColor: thresholdColor(clamped, theme, marks),
33
52
  trackColor: theme.gauge.track,
34
53
  pct: clamped,
35
54
  };
@@ -117,6 +117,17 @@ export interface Glyphs {
117
117
  * scan fails the build if either spelling appears anywhere else.
118
118
  */
119
119
  retry: string;
120
+ /**
121
+ * Context compaction (context-auto-compaction §6.3). The rail marker on the
122
+ * compaction card.
123
+ *
124
+ * HERE RATHER THAN IN `CompactionCard`, for the reason this file's header
125
+ * gives: a literal in a component bypasses `pickGlyphs` entirely and shows
126
+ * mojibake on a legacy console whatever the capability probe said.
127
+ * `glyphs.test.ts`'s static scan fails the build if either spelling appears
128
+ * anywhere else — and `src/compaction/**` is inside that scan's scope too.
129
+ */
130
+ compaction: string;
120
131
  /**
121
132
  * Ink `borderStyle` name for every boxed region (composer, completion popup,
122
133
  * overlay frame).
package/dist/ui/glyphs.js CHANGED
@@ -65,6 +65,7 @@ const UNICODE_GLYPHS = {
65
65
  todoActive: '▸',
66
66
  todoDone: '✔',
67
67
  retry: '↻',
68
+ compaction: '⤓',
68
69
  tool: {
69
70
  read_file: '▤',
70
71
  write_file: '✎',
@@ -127,6 +128,7 @@ const ASCII_GLYPHS = {
127
128
  todoActive: '>',
128
129
  todoDone: '[x]',
129
130
  retry: '[r]',
131
+ compaction: '[c]',
130
132
  tool: {
131
133
  read_file: '=',
132
134
  write_file: '+',
@@ -11,11 +11,23 @@
11
11
  * *display* number back. Holding the offset in the parent as well would create a
12
12
  * measure → setState → re-render → measure loop whose convergence is provable
13
13
  * but whose frame count is not — expensive inside a 33 ms streaming window.
14
+ *
15
+ * AS OF tui-selection-and-scroll-follow THIS IS AN ADAPTER, NOT A POLICY OWNER.
16
+ * The two follow rules are pure and live in `follow-state.ts`; what stays here is
17
+ * measure → compute a tail delta → apply → arm a timer. `scroll.ts` is
18
+ * unchanged, so every existing scroll test keeps passing (R-10).
19
+ *
20
+ * The "N new lines · PgDn" hint used to take this viewport's LAST ROW. It now
21
+ * renders as a chip inside the composer's bordered input row (§4.2), which costs
22
+ * zero rows and removes the feedback loop where the hint's own presence shrank
23
+ * the viewport, which changed `overflowLines`, which was the number the hint
24
+ * displayed. The data still leaves through `onScrolledLinesChange`.
14
25
  */
15
26
  import React from 'react';
16
27
  import type { Theme } from '../theme.js';
17
28
  import type { TermCapabilities } from '../capabilities.js';
18
29
  import { type ScrollIntent } from './scroll.js';
30
+ import { type TailSink } from './follow-state.js';
19
31
  export interface ScrollViewportProps {
20
32
  /**
21
33
  * Scroll intent. `nonce` is monotonic so pressing the same key twice re-fires.
@@ -30,6 +42,31 @@ export interface ScrollViewportProps {
30
42
  pinToBottomNonce?: number;
31
43
  /** Derived display value for the status bar's `↑N`. Must be referentially stable. */
32
44
  onScrolledLinesChange?: (lines: number) => void;
45
+ /**
46
+ * Rows hidden ABOVE the window, published on every change (§4.3.3).
47
+ *
48
+ * PUBLISHED, NOT LIFTED — the same shape as `onScrolledLinesChange`, so there
49
+ * is still exactly one owner of the offset. `shiftUp` changes exactly when the
50
+ * rows on screen move, which is the property the selection's clear-list was
51
+ * approximating by enumeration (I-9 / P1-4). Must be referentially stable.
52
+ */
53
+ onViewportShiftChange?: (shiftUp: number) => void;
54
+ /**
55
+ * The transcript's tail row counter (§4.3.1a), written during ITS render and
56
+ * therefore already current when this component's layout effect runs.
57
+ *
58
+ * ABSENT ⇒ NO ANCHORING AT ALL, deliberately: every existing caller and every
59
+ * existing test that does not supply it behaves exactly as it did before this
60
+ * feature, because `tailDelta` is then permanently 0.
61
+ */
62
+ tailRowsRef?: TailSink;
63
+ /**
64
+ * True while a selection drag is in progress (§4.4). It anchors the content
65
+ * even from a pinned viewport, and it suspends the resume timer.
66
+ */
67
+ hold?: boolean;
68
+ /** Idle delay before returning to the newest line, in ms; `0` disables (AC-9). */
69
+ resumeMs?: number;
33
70
  /** Reserve the right-edge scroll-position rail (mouse-wheel §4.8). */
34
71
  showScrollIndicator?: boolean;
35
72
  /**
@@ -37,15 +74,15 @@ export interface ScrollViewportProps {
37
74
  * (todo-plan-execution §3.9 / C-7).
38
75
  *
39
76
  * This component is the ONLY one inside the viewport subtree that reads
40
- * `stdout.columns` directly, and both places it uses the number — the terse
41
- * hint threshold and the `MIN_INDICATOR_COLS` gate — are about how much room
42
- * THIS BOX has, not how wide the terminal is. Once the todo rail takes a fifth
43
- * of the frame those two stop being the same number. Optional so every
44
- * existing caller (and every test) is unchanged.
77
+ * `stdout.columns` directly, and the place it uses the number — the
78
+ * `MIN_INDICATOR_COLS` gate — is about how much room THIS BOX has, not how
79
+ * wide the terminal is. Once the todo rail takes a fifth of the frame those two
80
+ * stop being the same number. Optional so every existing caller (and every
81
+ * test) is unchanged.
45
82
  */
46
83
  cols?: number;
47
84
  theme: Theme;
48
85
  caps: TermCapabilities;
49
86
  children: React.ReactNode;
50
87
  }
51
- export declare function ScrollViewport({ intent, pinToBottomNonce, onScrolledLinesChange, showScrollIndicator, cols: colsProp, theme, caps, children, }: ScrollViewportProps): React.ReactElement;
88
+ export declare function ScrollViewport({ intent, pinToBottomNonce, onScrolledLinesChange, onViewportShiftChange, tailRowsRef, hold, resumeMs, showScrollIndicator, cols: colsProp, theme, caps, children, }: ScrollViewportProps): React.ReactElement;