@aragon-agent/cli 0.6.2 → 0.6.4

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 (167) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README.md +297 -2
  3. package/dist/agent/controller.d.ts +193 -1
  4. package/dist/agent/controller.js +475 -2
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +189 -0
  8. package/dist/agent/reducer.js +301 -2
  9. package/dist/agent/system-prompt.d.ts +14 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +297 -16
  12. package/dist/commands/builtins.js +165 -24
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +118 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +355 -0
  20. package/dist/compaction/compactor.d.ts +302 -0
  21. package/dist/compaction/compactor.js +810 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +215 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +264 -0
  37. package/dist/compaction/wiring.js +577 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +5 -2
  43. package/dist/config/env.js +140 -3
  44. package/dist/config/load.d.ts +42 -0
  45. package/dist/config/load.js +101 -1
  46. package/dist/config/schema.d.ts +244 -0
  47. package/dist/config/schema.js +170 -0
  48. package/dist/config/store.js +37 -1
  49. package/dist/config/ui-state.d.ts +28 -1
  50. package/dist/config/ui-state.js +9 -0
  51. package/dist/exec/events.d.ts +50 -1
  52. package/dist/exec/runner.js +42 -0
  53. package/dist/input/limits.d.ts +72 -0
  54. package/dist/input/limits.js +63 -0
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/paste-parse.d.ts +71 -0
  58. package/dist/input/paste-parse.js +95 -0
  59. package/dist/input/stdin-filter.d.ts +81 -0
  60. package/dist/input/stdin-filter.js +379 -0
  61. package/dist/logging/install.d.ts +53 -1
  62. package/dist/logging/install.js +81 -1
  63. package/dist/logging/logger.d.ts +1 -1
  64. package/dist/proc/classify.d.ts +52 -0
  65. package/dist/proc/classify.js +143 -0
  66. package/dist/proc/kill-tree.d.ts +42 -0
  67. package/dist/proc/kill-tree.js +119 -0
  68. package/dist/proc/limits.d.ts +84 -0
  69. package/dist/proc/limits.js +84 -0
  70. package/dist/proc/log-ring.d.ts +68 -0
  71. package/dist/proc/log-ring.js +102 -0
  72. package/dist/proc/prompt.d.ts +24 -0
  73. package/dist/proc/prompt.js +49 -0
  74. package/dist/proc/readiness.d.ts +102 -0
  75. package/dist/proc/readiness.js +219 -0
  76. package/dist/proc/supervisor.d.ts +103 -0
  77. package/dist/proc/supervisor.js +465 -0
  78. package/dist/proc/types.d.ts +175 -0
  79. package/dist/proc/types.js +15 -0
  80. package/dist/session/persist.js +55 -1
  81. package/dist/team/report.js +7 -1
  82. package/dist/team/runtime.d.ts +10 -0
  83. package/dist/team/runtime.js +7 -0
  84. package/dist/team/subagent.d.ts +31 -1
  85. package/dist/team/subagent.js +42 -15
  86. package/dist/team/types.d.ts +8 -0
  87. package/dist/tools/bash-tool.d.ts +51 -2
  88. package/dist/tools/bash-tool.js +321 -51
  89. package/dist/tools/fs-tools.d.ts +17 -0
  90. package/dist/tools/index.d.ts +55 -2
  91. package/dist/tools/index.js +39 -1
  92. package/dist/tools/proc-tools.d.ts +25 -0
  93. package/dist/tools/proc-tools.js +102 -0
  94. package/dist/ui/ActivityLine.d.ts +19 -1
  95. package/dist/ui/ActivityLine.js +10 -2
  96. package/dist/ui/App.d.ts +62 -2
  97. package/dist/ui/App.js +546 -16
  98. package/dist/ui/Composer.d.ts +66 -1
  99. package/dist/ui/Composer.js +50 -6
  100. package/dist/ui/PromptInput.d.ts +82 -3
  101. package/dist/ui/PromptInput.js +232 -49
  102. package/dist/ui/StatusBar.d.ts +59 -0
  103. package/dist/ui/StatusBar.js +16 -4
  104. package/dist/ui/TeamPanel.js +7 -1
  105. package/dist/ui/Transcript.d.ts +21 -1
  106. package/dist/ui/Transcript.js +61 -3
  107. package/dist/ui/clipboard.d.ts +52 -0
  108. package/dist/ui/clipboard.js +91 -0
  109. package/dist/ui/composer-limits.d.ts +31 -0
  110. package/dist/ui/composer-limits.js +50 -0
  111. package/dist/ui/composer-rows.d.ts +73 -0
  112. package/dist/ui/composer-rows.js +201 -0
  113. package/dist/ui/editor-reducer.d.ts +37 -0
  114. package/dist/ui/editor-reducer.js +88 -12
  115. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  116. package/dist/ui/entries/CompactionCard.js +158 -0
  117. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  118. package/dist/ui/entries/ServiceCard.js +116 -0
  119. package/dist/ui/entries/UserEntry.d.ts +17 -3
  120. package/dist/ui/entries/UserEntry.js +34 -5
  121. package/dist/ui/frame-differ.d.ts +46 -0
  122. package/dist/ui/frame-differ.js +94 -10
  123. package/dist/ui/gauge.d.ts +16 -1
  124. package/dist/ui/gauge.js +25 -6
  125. package/dist/ui/glyphs.d.ts +11 -0
  126. package/dist/ui/glyphs.js +2 -0
  127. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  128. package/dist/ui/layout/ScrollViewport.js +128 -21
  129. package/dist/ui/layout/budget.d.ts +44 -9
  130. package/dist/ui/layout/budget.js +42 -10
  131. package/dist/ui/layout/follow-state.d.ts +137 -0
  132. package/dist/ui/layout/follow-state.js +150 -0
  133. package/dist/ui/layout/virtual-window.d.ts +11 -0
  134. package/dist/ui/layout/virtual-window.js +85 -1
  135. package/dist/ui/overlays/HelpOverlay.js +9 -1
  136. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  137. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  138. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  139. package/dist/ui/overlays/SettingsScreen.js +74 -2
  140. package/dist/ui/palettes.d.ts +16 -0
  141. package/dist/ui/palettes.js +6 -0
  142. package/dist/ui/paste-frames.d.ts +87 -0
  143. package/dist/ui/paste-frames.js +112 -0
  144. package/dist/ui/paste-tokens.d.ts +90 -0
  145. package/dist/ui/paste-tokens.js +147 -0
  146. package/dist/ui/screen.d.ts +38 -0
  147. package/dist/ui/screen.js +113 -8
  148. package/dist/ui/selection/highlight.d.ts +39 -0
  149. package/dist/ui/selection/highlight.js +64 -0
  150. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  151. package/dist/ui/selection/screen-mirror.js +35 -0
  152. package/dist/ui/selection/selection-controller.d.ts +88 -0
  153. package/dist/ui/selection/selection-controller.js +282 -0
  154. package/dist/ui/selection/selection.d.ts +90 -0
  155. package/dist/ui/selection/selection.js +223 -0
  156. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  157. package/dist/ui/stdout-frame-writer.js +15 -0
  158. package/dist/ui/theme.d.ts +11 -0
  159. package/dist/ui/theme.js +2 -0
  160. package/dist/ui/transcript-text.js +72 -0
  161. package/dist/ui/use-startup-notices.d.ts +27 -1
  162. package/dist/ui/use-startup-notices.js +43 -11
  163. package/dist/ui/use-wheel-routing.d.ts +1 -1
  164. package/dist/ui/use-wheel-routing.js +8 -0
  165. package/package.json +4 -2
  166. package/dist/input/stdin-mouse-filter.d.ts +0 -42
  167. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Composer layout (tui-paste-handling section 5.5) -- pure. No Ink, no React.
3
+ *
4
+ * THE ONE PLACE THAT MAPS A BUFFER INDEX TO A SCREEN CELL (I-9). The composer
5
+ * used to hand its buffer to Ink one `<Text>` per logical line and let Yoga wrap
6
+ * it, which is fine until the draft is taller than the frame: `AppShell` gives
7
+ * the bottom chrome `flexShrink={0}`, so a tall composer does not grow the frame
8
+ * -- Yoga takes the rows out of the transcript and `overflow: hidden` clips them
9
+ * away, while `layout/budget.ts` keeps telling five consumers the composer is
10
+ * three rows tall. Four hundred presses of `Shift+Enter` do it without pasting
11
+ * anything.
12
+ *
13
+ * So the wrap happens HERE, the row count is a number this module returns, and
14
+ * `budget.ts` is told that number (I-8).
15
+ *
16
+ * HARD WRAP BY DISPLAY COLUMN, NEVER `layout/wrap-rows.ts::wrapToRows` (D-15).
17
+ * That function is a PROSE wrapper: it splits on `/\s+/` and rejoins with single
18
+ * spaces, so it destroys indentation and makes the character-index-to-screen-cell
19
+ * mapping the caret depends on impossible to state, let alone compute.
20
+ */
21
+ import stringWidth from 'string-width';
22
+ import { tokenSpans } from './paste-tokens.js';
23
+ /**
24
+ * Columns a TAB advances to.
25
+ *
26
+ * TABS ARE EXPANDED TO SPACES FOR DISPLAY ONLY -- the buffer keeps the tab, so
27
+ * the model receives exactly what was pasted. The alternative is to emit the raw
28
+ * tab and let the terminal expand it, which puts the terminal's tab stop (8) and
29
+ * this module's column arithmetic in disagreement: the row would overflow its box
30
+ * and the terminal would wrap it a second time, which is the corruption the whole
31
+ * feature exists to remove.
32
+ */
33
+ const TAB_WIDTH = 4;
34
+ /**
35
+ * Display width of one code point.
36
+ *
37
+ * The ASCII fast path matters: `layoutComposer` runs on every keystroke and a
38
+ * recalled prompt-history entry can be thousands of characters, so one
39
+ * `string-width` call per character would be the cost this package spent
40
+ * `tui-render-performance` removing.
41
+ */
42
+ function charWidth(ch) {
43
+ const code = ch.codePointAt(0) ?? 0;
44
+ if (code >= 0x20 && code < 0x7f)
45
+ return 1;
46
+ return stringWidth(ch);
47
+ }
48
+ /** Total display width of a run, using the same table the layout used. */
49
+ export function displayWidth(text) {
50
+ let w = 0;
51
+ for (const ch of text)
52
+ w += charWidth(ch);
53
+ return w;
54
+ }
55
+ /**
56
+ * Wrap `buffer` into rows, locate the caret, and select a window of at most
57
+ * `maxRows` rows that contains it.
58
+ *
59
+ * The window is pinned to the BOTTOM -- where the user is typing -- and pulled up
60
+ * only far enough to keep the caret visible. That is deterministic, which matters
61
+ * because this function holds no state between calls: a scroll offset would have
62
+ * to live in the reducer and would then need its own invalidation rules.
63
+ */
64
+ export function layoutComposer(input) {
65
+ const { buffer, cursor, active } = input;
66
+ const cols = Math.max(1, Math.floor(input.cols));
67
+ const maxRows = input.maxRows >= 1 ? Math.floor(input.maxRows) : 1;
68
+ const spans = tokenSpans(buffer);
69
+ let spanIndex = 0;
70
+ /** Monotonic in `index`, which is why the pointer is safe. */
71
+ const kindAt = (index) => {
72
+ while (spanIndex < spans.length && spans[spanIndex].end <= index)
73
+ spanIndex += 1;
74
+ const span = spans[spanIndex];
75
+ return span && index >= span.start && index < span.end ? 'token' : 'text';
76
+ };
77
+ const rows = [];
78
+ let segments = [];
79
+ let col = 0;
80
+ let cursorRowAbs = -1;
81
+ let cursorCol = 0;
82
+ const breakRow = () => {
83
+ rows.push({ segments });
84
+ segments = [];
85
+ col = 0;
86
+ };
87
+ const append = (text, width, kind) => {
88
+ const last = segments[segments.length - 1];
89
+ if (last && last.kind === kind)
90
+ segments[segments.length - 1] = { text: last.text + text, kind };
91
+ else
92
+ segments.push({ text, kind });
93
+ col += width;
94
+ };
95
+ const ensureRoom = (width) => {
96
+ if (col > 0 && col + width > cols)
97
+ breakRow();
98
+ };
99
+ const length = buffer.length;
100
+ let i = 0;
101
+ while (i <= length) {
102
+ const atCursor = active && i === cursor && cursorRowAbs === -1;
103
+ if (i === length) {
104
+ if (atCursor) {
105
+ ensureRoom(1);
106
+ cursorRowAbs = rows.length;
107
+ cursorCol = col;
108
+ }
109
+ break;
110
+ }
111
+ const ch = String.fromCodePoint(buffer.codePointAt(i));
112
+ if (ch === '\n') {
113
+ if (atCursor) {
114
+ cursorRowAbs = rows.length;
115
+ cursorCol = col;
116
+ }
117
+ breakRow();
118
+ i += 1;
119
+ continue;
120
+ }
121
+ const kind = kindAt(i);
122
+ if (ch === '\t') {
123
+ let advance = TAB_WIDTH - (col % TAB_WIDTH);
124
+ if (col > 0 && col + advance > cols) {
125
+ breakRow();
126
+ advance = TAB_WIDTH;
127
+ }
128
+ if (atCursor) {
129
+ cursorRowAbs = rows.length;
130
+ cursorCol = col;
131
+ }
132
+ append(' '.repeat(advance), advance, kind);
133
+ i += 1;
134
+ continue;
135
+ }
136
+ const width = charWidth(ch);
137
+ ensureRoom(width);
138
+ if (atCursor) {
139
+ cursorRowAbs = rows.length;
140
+ cursorCol = col;
141
+ }
142
+ append(ch, width, kind);
143
+ i += ch.length;
144
+ }
145
+ rows.push({ segments });
146
+ const totalRows = rows.length;
147
+ const cap = Math.max(1, Math.min(maxRows, totalRows));
148
+ let start = Math.max(0, totalRows - cap);
149
+ if (cursorRowAbs >= 0) {
150
+ if (cursorRowAbs < start)
151
+ start = cursorRowAbs;
152
+ else if (cursorRowAbs >= start + cap)
153
+ start = Math.min(totalRows - cap, cursorRowAbs - cap + 1);
154
+ }
155
+ const windowed = rows.slice(start, start + cap);
156
+ return {
157
+ rows: windowed,
158
+ cursorRow: cursorRowAbs >= 0 ? cursorRowAbs - start : -1,
159
+ cursorCol,
160
+ totalRows,
161
+ hiddenAbove: start,
162
+ hiddenBelow: totalRows - start - windowed.length,
163
+ };
164
+ }
165
+ /**
166
+ * Split one row at a display column so the renderer can draw an inverse caret on
167
+ * exactly one cell.
168
+ *
169
+ * Lives here rather than in the component because it walks the SAME width table
170
+ * `layoutComposer` walked (I-9): a second, subtly different measurement is how
171
+ * the caret ends up drawn on a cell that does not hold the character it is on.
172
+ */
173
+ export function splitRowAtColumn(row, column) {
174
+ const before = [];
175
+ const after = [];
176
+ let at = null;
177
+ let x = 0;
178
+ const push = (list, text, kind) => {
179
+ const last = list[list.length - 1];
180
+ if (last && last.kind === kind)
181
+ list[list.length - 1] = { text: last.text + text, kind };
182
+ else
183
+ list.push({ text, kind });
184
+ };
185
+ for (const segment of row.segments) {
186
+ if (at !== null) {
187
+ push(after, segment.text, segment.kind);
188
+ continue;
189
+ }
190
+ for (const ch of segment.text) {
191
+ if (at === null && x === column) {
192
+ at = { text: ch, kind: segment.kind };
193
+ }
194
+ else {
195
+ push(at === null ? before : after, ch, segment.kind);
196
+ }
197
+ x += charWidth(ch);
198
+ }
199
+ }
200
+ return { before, at, after };
201
+ }
@@ -22,13 +22,41 @@
22
22
  *
23
23
  * Extracted from the component so it is unit-testable without mounting Ink.
24
24
  */
25
+ import { type PasteRecord } from './paste-tokens.js';
25
26
  export interface EditorState {
26
27
  buffer: string;
27
28
  cursor: number;
28
29
  historyIndex: number | null;
29
30
  dismissed: boolean;
30
31
  sel: number;
32
+ /**
33
+ * Payloads for the collapse tokens currently in `buffer`
34
+ * (tui-paste-handling section 5.3).
35
+ *
36
+ * Pruned to `referencedIds(buffer)` on EVERY buffer-changing action and
37
+ * emptied by `clear` (I-7). Without both, a payload outlives the draft that
38
+ * referenced it, unbounded, for the whole session.
39
+ */
40
+ pastes: ReadonlyMap<number, PasteRecord>;
31
41
  }
42
+ /**
43
+ * One run of an `input` chunk that mixed typing and pasting.
44
+ *
45
+ * THE `id` IS ALLOCATED BY THE CALLER (P1-5). React may run a reducer more than
46
+ * once for a single action -- eager evaluation in `dispatchSetState`, base-queue
47
+ * replay after a bailout, StrictMode -- so a counter read and written inside one
48
+ * would skip ids and produce two computed states differing in the token TEXT.
49
+ * `PromptInput` calls `allocatePasteId()` while building these segments, and the
50
+ * reducer stays a pure function of `(state, action)`.
51
+ */
52
+ export type InputSegment = {
53
+ readonly kind: 'text';
54
+ readonly text: string;
55
+ } | {
56
+ readonly kind: 'paste';
57
+ readonly text: string;
58
+ readonly id: number;
59
+ };
32
60
  /**
33
61
  * A `type` and not an `interface` because it is a discriminated union — the one
34
62
  * documented exception to this package's interface-first convention.
@@ -57,6 +85,15 @@ export type EditorAction = {
57
85
  type: 'dismiss';
58
86
  } | {
59
87
  type: 'clear';
88
+ }
89
+ /**
90
+ * ONE chunk of stdin, already split into ordered runs. The only new action,
91
+ * and it is what keeps the one-dispatch-per-key rule true for a chunk that
92
+ * carried both a paste and the keystrokes beside it.
93
+ */
94
+ | {
95
+ type: 'input';
96
+ segments: InputSegment[];
60
97
  };
61
98
  export declare const INITIAL_EDITOR_STATE: EditorState;
62
99
  export declare function editorReducer(state: EditorState, action: EditorAction): EditorState;
@@ -22,12 +22,15 @@
22
22
  *
23
23
  * Extracted from the component so it is unit-testable without mounting Ink.
24
24
  */
25
+ import { expandRangeOverTokens, formatPasteToken, makePasteRecord, referencedIds, shouldCollapse, snapOutOfToken, } from './paste-tokens.js';
26
+ const NO_PASTES = new Map();
25
27
  export const INITIAL_EDITOR_STATE = {
26
28
  buffer: '',
27
29
  cursor: 0,
28
30
  historyIndex: null,
29
31
  dismissed: false,
30
32
  sel: 0,
33
+ pastes: NO_PASTES,
31
34
  };
32
35
  /**
33
36
  * The three flags `resetDraftFlags()` used to clear with three separate
@@ -40,26 +43,99 @@ export const INITIAL_EDITOR_STATE = {
40
43
  * (the candidate list is about to be recomputed).
41
44
  */
42
45
  const DRAFT_FLAGS = { historyIndex: null, dismissed: false, sel: 0 };
46
+ /**
47
+ * Drop payloads the buffer no longer references (I-7).
48
+ *
49
+ * A WRAPPER, NOT A LINE IN ONE BRANCH (P1-6). v1 of the design pruned only inside
50
+ * `input`, which leaves `replace` (the action behind `Ctrl+U` / `Ctrl+K` /
51
+ * `Ctrl+W` and every `applyEdit`), `backspace` and `recall` able to delete a token
52
+ * without releasing its payload. A rule the body contradicts is worse than no
53
+ * rule, because the next reader trusts the invariant.
54
+ *
55
+ * `moveCursor`, `select` and `dismiss` do not change the buffer and are left
56
+ * alone, which keeps their "return the same object so React bails out" property.
57
+ */
58
+ function withPrune(next) {
59
+ if (next.pastes.size === 0)
60
+ return next;
61
+ const live = referencedIds(next.buffer);
62
+ // EVERY held id is tested, rather than `live.size === next.pastes.size`.
63
+ // Equal counts do not mean equal sets: `referencedIds` reads the BUFFER, which
64
+ // can name an id this map never held — a user who types `[Pasted text #99 +2
65
+ // lines]` by hand, or edits the number inside a real token. One such id
66
+ // balances out one deleted token and the cheap check then returns early with a
67
+ // payload the buffer no longer references, which is the leak I-7 forbids.
68
+ let stale = false;
69
+ for (const id of next.pastes.keys()) {
70
+ if (!live.has(id)) {
71
+ stale = true;
72
+ break;
73
+ }
74
+ }
75
+ if (!stale)
76
+ return next;
77
+ const kept = new Map();
78
+ for (const [id, record] of next.pastes)
79
+ if (live.has(id))
80
+ kept.set(id, record);
81
+ return { ...next, pastes: kept };
82
+ }
43
83
  export function editorReducer(state, action) {
44
84
  switch (action.type) {
45
- case 'insert':
46
- return {
85
+ case 'insert': {
86
+ // D-8: typing can never land INSIDE a token. A no-op on a buffer with no
87
+ // tokens, which is every buffer until the first collapse.
88
+ const cursor = snapOutOfToken(state.buffer, state.cursor);
89
+ return withPrune({
47
90
  ...state,
48
91
  ...DRAFT_FLAGS,
49
- buffer: state.buffer.slice(0, state.cursor) + action.text + state.buffer.slice(state.cursor),
50
- cursor: state.cursor + action.text.length,
51
- };
92
+ buffer: state.buffer.slice(0, cursor) + action.text + state.buffer.slice(cursor),
93
+ cursor: cursor + action.text.length,
94
+ });
95
+ }
52
96
  case 'replace':
53
- return { ...state, ...DRAFT_FLAGS, buffer: action.buffer, cursor: action.cursor };
97
+ return withPrune({
98
+ ...state,
99
+ ...DRAFT_FLAGS,
100
+ buffer: action.buffer,
101
+ cursor: action.cursor,
102
+ });
54
103
  case 'backspace': {
55
104
  if (state.cursor <= 0)
56
105
  return state;
57
- return {
106
+ // G5: one `Backspace` immediately after `...lines]` removes the whole
107
+ // token and releases its payload, in ONE dispatch.
108
+ const range = expandRangeOverTokens(state.buffer, state.cursor - 1, state.cursor);
109
+ return withPrune({
110
+ ...state,
111
+ ...DRAFT_FLAGS,
112
+ buffer: state.buffer.slice(0, range.from) + state.buffer.slice(range.to),
113
+ cursor: range.from,
114
+ });
115
+ }
116
+ case 'input': {
117
+ let buffer = state.buffer;
118
+ let cursor = snapOutOfToken(buffer, state.cursor);
119
+ let pastes = null;
120
+ for (const segment of action.segments) {
121
+ const collapse = segment.kind === 'paste' && shouldCollapse(segment.text);
122
+ let text = segment.text;
123
+ if (collapse) {
124
+ const record = makePasteRecord(segment.id, segment.text);
125
+ text = formatPasteToken(record);
126
+ pastes = pastes ?? new Map(state.pastes);
127
+ pastes.set(record.id, record);
128
+ }
129
+ buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
130
+ cursor += text.length;
131
+ }
132
+ return withPrune({
58
133
  ...state,
59
134
  ...DRAFT_FLAGS,
60
- buffer: state.buffer.slice(0, state.cursor - 1) + state.buffer.slice(state.cursor),
61
- cursor: state.cursor - 1,
62
- };
135
+ buffer,
136
+ cursor,
137
+ ...(pastes ? { pastes } : {}),
138
+ });
63
139
  }
64
140
  case 'moveCursor':
65
141
  // NO DRAFT-FLAG RESET. Moving the caret is not an edit: clearing
@@ -74,12 +150,12 @@ export function editorReducer(state, action) {
74
150
  // an Esc-dismissed popup that reopened when the user stepped through their
75
151
  // history would be a behaviour change smuggled in by a refactor whose whole
76
152
  // claim is that input semantics are identical (K-7).
77
- return {
153
+ return withPrune({
78
154
  ...state,
79
155
  buffer: action.buffer,
80
156
  cursor: action.cursor,
81
157
  historyIndex: action.historyIndex,
82
- };
158
+ });
83
159
  case 'select':
84
160
  return state.sel === action.sel ? state : { ...state, sel: action.sel };
85
161
  case 'dismiss':
@@ -0,0 +1,75 @@
1
+ /**
2
+ * CompactionCard — one context compaction in the transcript
3
+ * (context-auto-compaction §6.3).
4
+ *
5
+ * ASCII ONLY: every glyph comes from `pickGlyphs`, INCLUDING THE ARROW. The mock
6
+ * in the design shows `112 -> 9 messages`; that arrow is `glyphs.arrowRight`, the
7
+ * one `StatusBar` already uses, not a literal — `ui/` is inside
8
+ * `glyphs.test.ts::inScope`, so a literal here fails the scan (P2-7).
9
+ *
10
+ * EXPANDABLE, UNLIKE `FastCard`. A critique is already clamped to
11
+ * `fast.reviewMaxChars` before it reaches its card, so there is nothing hidden
12
+ * for Ctrl+O to reveal; a summary is up to `summaryMaxChars` and there genuinely
13
+ * is more to see. That is why `App`'s Ctrl+O predicate had to learn about this
14
+ * kind — without it the card renders a hint that does nothing and expands an
15
+ * older tool card instead (C-15 / P1-8 / AC-21).
16
+ *
17
+ * EVERY STRING DESCRIBES THE HISTORY, NEVER THE SCREEN (§6.4 / P2-9). `/clear`
18
+ * empties the transcript view but not `messages`, so this card can legitimately
19
+ * report "112 -> 9 messages" with nine visible rows above it. Nothing here says
20
+ * "above" or "earlier in this transcript".
21
+ */
22
+ import React from 'react';
23
+ import type { Theme } from '../theme.js';
24
+ import type { TermCapabilities } from '../capabilities.js';
25
+ import type { CompactionMode, CompactionUiTrigger } from '../../compaction/types.js';
26
+ export interface CompactionCardProps {
27
+ index: number;
28
+ trigger: CompactionUiTrigger;
29
+ mode: CompactionMode;
30
+ applied: boolean;
31
+ reason?: string;
32
+ messagesBefore: number;
33
+ messagesAfter: number;
34
+ tokensBefore: number;
35
+ tokensAfter: number;
36
+ summary?: string;
37
+ model: string;
38
+ durationMs?: number;
39
+ /**
40
+ * Wall-clock since the card opened, LIVE ONLY
41
+ * (context-auto-compaction-hardening §3.6 / W5).
42
+ *
43
+ * Rendered in the position `durationMs` occupies once settled, so the number
44
+ * does not move when the card settles. Only the single live card receives it,
45
+ * so exactly one memoized card re-renders per second.
46
+ */
47
+ elapsedMs?: number;
48
+ /** Set when tail relief clipped the retained turns (W2). */
49
+ tailRelief?: {
50
+ messages: number;
51
+ charsRemoved: number;
52
+ };
53
+ live: boolean;
54
+ expanded?: boolean;
55
+ reducedMotion?: boolean;
56
+ theme: Theme;
57
+ caps: TermCapabilities;
58
+ }
59
+ /**
60
+ * The card's colour.
61
+ *
62
+ * `truncated` is a WARNING rather than the accent, because it is a real and
63
+ * bounded data loss the user is entitled to notice; `applied: false` is an error
64
+ * because the run is still at the occupancy that triggered it.
65
+ */
66
+ export declare function compactionCardColor(props: Pick<CompactionCardProps, 'live' | 'applied' | 'mode'>, theme: Theme): string | undefined;
67
+ declare function CompactionCardImpl(props: CompactionCardProps): React.ReactElement;
68
+ /**
69
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
70
+ * `mapEntry` already preserves object identity for untouched entries, so the
71
+ * props below are stable references on a settled card, and `theme` / `caps` are
72
+ * `useMemo`d in `App.tsx` (I-L2-1).
73
+ */
74
+ export declare const CompactionCard: React.MemoExoticComponent<typeof CompactionCardImpl>;
75
+ export {};
@@ -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);