@aragon-agent/cli 0.6.3 → 0.6.7

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 (138) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +203 -13
  3. package/dist/agent/controller.d.ts +158 -2
  4. package/dist/agent/controller.js +400 -1
  5. package/dist/agent/reducer.d.ts +102 -19
  6. package/dist/agent/reducer.js +162 -71
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/agent/usage.d.ts +34 -0
  10. package/dist/agent/usage.js +43 -0
  11. package/dist/cli.js +132 -23
  12. package/dist/commands/builtins.js +101 -1
  13. package/dist/compaction/child.js +5 -0
  14. package/dist/compaction/command.js +14 -1
  15. package/dist/compaction/compactor.d.ts +94 -24
  16. package/dist/compaction/compactor.js +247 -98
  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 +77 -0
  24. package/dist/compaction/wiring.d.ts +56 -0
  25. package/dist/compaction/wiring.js +107 -25
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.d.ts +1 -1
  28. package/dist/config/env.js +63 -2
  29. package/dist/config/load.d.ts +28 -0
  30. package/dist/config/load.js +83 -1
  31. package/dist/config/schema.d.ts +129 -0
  32. package/dist/config/schema.js +69 -0
  33. package/dist/config/store.js +47 -1
  34. package/dist/exec/events.d.ts +138 -1
  35. package/dist/exec/events.js +42 -0
  36. package/dist/exec/index.js +43 -6
  37. package/dist/exec/progress-reporter.d.ts +23 -0
  38. package/dist/exec/progress-reporter.js +88 -0
  39. package/dist/exec/runner.d.ts +34 -0
  40. package/dist/exec/runner.js +137 -25
  41. package/dist/fast/reviewer.d.ts +19 -1
  42. package/dist/fast/reviewer.js +22 -0
  43. package/dist/fast/types.d.ts +15 -0
  44. package/dist/fast/wiring.d.ts +10 -1
  45. package/dist/fast/wiring.js +18 -1
  46. package/dist/input/limits.d.ts +72 -0
  47. package/dist/input/limits.js +63 -0
  48. package/dist/input/paste-parse.d.ts +71 -0
  49. package/dist/input/paste-parse.js +95 -0
  50. package/dist/input/stdin-filter.d.ts +81 -0
  51. package/dist/input/stdin-filter.js +379 -0
  52. package/dist/logging/install.d.ts +2 -0
  53. package/dist/logging/install.js +53 -1
  54. package/dist/proc/classify.d.ts +52 -0
  55. package/dist/proc/classify.js +143 -0
  56. package/dist/proc/kill-tree.d.ts +42 -0
  57. package/dist/proc/kill-tree.js +119 -0
  58. package/dist/proc/limits.d.ts +84 -0
  59. package/dist/proc/limits.js +84 -0
  60. package/dist/proc/log-ring.d.ts +68 -0
  61. package/dist/proc/log-ring.js +102 -0
  62. package/dist/proc/prompt.d.ts +24 -0
  63. package/dist/proc/prompt.js +49 -0
  64. package/dist/proc/readiness.d.ts +102 -0
  65. package/dist/proc/readiness.js +219 -0
  66. package/dist/proc/supervisor.d.ts +103 -0
  67. package/dist/proc/supervisor.js +465 -0
  68. package/dist/proc/types.d.ts +175 -0
  69. package/dist/proc/types.js +15 -0
  70. package/dist/session/persist.js +36 -1
  71. package/dist/todo/limits.d.ts +4 -17
  72. package/dist/todo/limits.js +4 -17
  73. package/dist/todo/panel-rows.d.ts +8 -6
  74. package/dist/todo/panel-rows.js +19 -6
  75. package/dist/tools/bash-tool.d.ts +51 -2
  76. package/dist/tools/bash-tool.js +321 -51
  77. package/dist/tools/fs-tools.d.ts +17 -0
  78. package/dist/tools/index.d.ts +55 -2
  79. package/dist/tools/index.js +39 -1
  80. package/dist/tools/proc-tools.d.ts +25 -0
  81. package/dist/tools/proc-tools.js +102 -0
  82. package/dist/ui/App.d.ts +15 -2
  83. package/dist/ui/App.js +330 -69
  84. package/dist/ui/AutocompletePopup.d.ts +3 -1
  85. package/dist/ui/AutocompletePopup.js +9 -11
  86. package/dist/ui/BottomStatusRow.d.ts +1 -1
  87. package/dist/ui/Composer.d.ts +56 -1
  88. package/dist/ui/Composer.js +50 -6
  89. package/dist/ui/PromptInput.d.ts +52 -3
  90. package/dist/ui/PromptInput.js +201 -55
  91. package/dist/ui/StatusBar.d.ts +40 -15
  92. package/dist/ui/StatusBar.js +51 -9
  93. package/dist/ui/TeamPanel.d.ts +3 -1
  94. package/dist/ui/TeamPanel.js +13 -9
  95. package/dist/ui/TodoPanel.d.ts +5 -5
  96. package/dist/ui/TodoPanel.js +56 -24
  97. package/dist/ui/Transcript.js +29 -1
  98. package/dist/ui/UpdateLine.d.ts +1 -1
  99. package/dist/ui/capabilities.js +5 -5
  100. package/dist/ui/composer-limits.d.ts +31 -0
  101. package/dist/ui/composer-limits.js +50 -0
  102. package/dist/ui/composer-rows.d.ts +73 -0
  103. package/dist/ui/composer-rows.js +201 -0
  104. package/dist/ui/editor-reducer.d.ts +37 -0
  105. package/dist/ui/editor-reducer.js +88 -12
  106. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  107. package/dist/ui/entries/ServiceCard.js +116 -0
  108. package/dist/ui/entries/UserEntry.d.ts +17 -3
  109. package/dist/ui/entries/UserEntry.js +34 -5
  110. package/dist/ui/exit-snapshot.d.ts +9 -0
  111. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  112. package/dist/ui/layout/autocomplete.d.ts +18 -0
  113. package/dist/ui/layout/autocomplete.js +26 -0
  114. package/dist/ui/layout/budget.d.ts +34 -6
  115. package/dist/ui/layout/budget.js +32 -7
  116. package/dist/ui/layout/rail.d.ts +4 -53
  117. package/dist/ui/layout/rail.js +10 -62
  118. package/dist/ui/layout/team-panel.d.ts +18 -0
  119. package/dist/ui/layout/team-panel.js +22 -0
  120. package/dist/ui/layout/todo-layout.d.ts +24 -0
  121. package/dist/ui/layout/todo-layout.js +27 -0
  122. package/dist/ui/layout/virtual-window.js +51 -1
  123. package/dist/ui/overlays/HelpOverlay.js +3 -0
  124. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  125. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  126. package/dist/ui/overlays/SettingsScreen.js +14 -1
  127. package/dist/ui/paste-frames.d.ts +87 -0
  128. package/dist/ui/paste-frames.js +112 -0
  129. package/dist/ui/paste-tokens.d.ts +90 -0
  130. package/dist/ui/paste-tokens.js +147 -0
  131. package/dist/ui/screen.d.ts +16 -0
  132. package/dist/ui/screen.js +45 -2
  133. package/dist/ui/selection/selection-controller.d.ts +1 -1
  134. package/dist/ui/transcript-text.js +55 -2
  135. package/dist/ui/use-wheel-routing.d.ts +1 -1
  136. package/package.json +2 -2
  137. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  138. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -0,0 +1,73 @@
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
+ export interface ComposerSegment {
22
+ readonly text: string;
23
+ readonly kind: 'text' | 'token';
24
+ }
25
+ export interface ComposerRow {
26
+ readonly segments: ComposerSegment[];
27
+ }
28
+ export interface ComposerLayout {
29
+ /** At most `maxRows`, and always containing the caret when `active`. */
30
+ readonly rows: ComposerRow[];
31
+ /** Index into `rows`, or -1 when the composer is not focused. */
32
+ readonly cursorRow: number;
33
+ /** Display column within that row. */
34
+ readonly cursorCol: number;
35
+ /** Rows the buffer occupies BEFORE the window was applied. */
36
+ readonly totalRows: number;
37
+ readonly hiddenAbove: number;
38
+ readonly hiddenBelow: number;
39
+ }
40
+ export interface ComposerLayoutInput {
41
+ readonly buffer: string;
42
+ readonly cursor: number;
43
+ readonly cols: number;
44
+ readonly maxRows: number;
45
+ readonly active: boolean;
46
+ }
47
+ /** Total display width of a run, using the same table the layout used. */
48
+ export declare function displayWidth(text: string): number;
49
+ /**
50
+ * Wrap `buffer` into rows, locate the caret, and select a window of at most
51
+ * `maxRows` rows that contains it.
52
+ *
53
+ * The window is pinned to the BOTTOM -- where the user is typing -- and pulled up
54
+ * only far enough to keep the caret visible. That is deterministic, which matters
55
+ * because this function holds no state between calls: a scroll offset would have
56
+ * to live in the reducer and would then need its own invalidation rules.
57
+ */
58
+ export declare function layoutComposer(input: ComposerLayoutInput): ComposerLayout;
59
+ export interface RowSplit {
60
+ readonly before: ComposerSegment[];
61
+ /** The one cell the caret sits on, or `null` when it sits past the last one. */
62
+ readonly at: ComposerSegment | null;
63
+ readonly after: ComposerSegment[];
64
+ }
65
+ /**
66
+ * Split one row at a display column so the renderer can draw an inverse caret on
67
+ * exactly one cell.
68
+ *
69
+ * Lives here rather than in the component because it walks the SAME width table
70
+ * `layoutComposer` walked (I-9): a second, subtly different measurement is how
71
+ * the caret ends up drawn on a cell that does not hold the character it is on.
72
+ */
73
+ export declare function splitRowAtColumn(row: ComposerRow, column: number): RowSplit;
@@ -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,66 @@
1
+ /**
2
+ * ServiceCard — one supervised background service in the transcript
3
+ * (background-service-supervision §3.8).
4
+ *
5
+ * ASCII ONLY: every glyph comes from `pickGlyphs`. `ui/` is inside
6
+ * `glyphs.test.ts::inScope`, so a literal here fails the scan.
7
+ *
8
+ * NO ANIMATED SPINNER, IN ANY STATE (D-14 / P2-7). That is STRICTER than
9
+ * `single-spinner-while-running` D-1, which suppresses animation only while the
10
+ * activity line is mounted — and a service can sit at `starting` while the agent
11
+ * is IDLE, where D-1's "one owner on screen" argument does not reach because
12
+ * there is no owner on screen. A card that animated only when the agent happened
13
+ * to be idle would be the worst of both. It uses `glyphs.spinnerStill`
14
+ * throughout and still accepts `reducedMotion` for consistency with
15
+ * `EntryView`'s comparator.
16
+ *
17
+ * TWO SHAPES, ONE COMPONENT. `terminal: true` is the one-row record a service's
18
+ * exit appends (D-11); everything else is the live card. They share a component
19
+ * because they share a colour ladder and a status vocabulary, and splitting them
20
+ * would be two places to keep those in step.
21
+ */
22
+ import React from 'react';
23
+ import type { Theme } from '../theme.js';
24
+ import type { TermCapabilities } from '../capabilities.js';
25
+ import { pickGlyphs } from '../glyphs.js';
26
+ import type { ServiceStatus } from '../../proc/types.js';
27
+ export interface ServiceCardProps {
28
+ serviceId: string;
29
+ command: string;
30
+ status: ServiceStatus;
31
+ url?: string;
32
+ port?: number;
33
+ exitCode: number | null;
34
+ startedAt: number;
35
+ readyAt?: number;
36
+ endedAt?: number;
37
+ rows: readonly string[];
38
+ /** The one-row record a terminal transition appends. */
39
+ terminal?: boolean;
40
+ killIncomplete?: boolean;
41
+ expanded?: boolean;
42
+ /** Accepted for comparator consistency; this card never animates (D-14). */
43
+ reducedMotion?: boolean;
44
+ theme: Theme;
45
+ caps: TermCapabilities;
46
+ }
47
+ /**
48
+ * The card's colour.
49
+ *
50
+ * `running` is `toolRunning` rather than the accent: it is alive and listening
51
+ * on nothing, which is a real third state and not a lesser `ready`. A clean
52
+ * `exited(0)` is muted, because a command that finished on purpose is not a
53
+ * failure.
54
+ */
55
+ export declare function serviceCardColor(props: Pick<ServiceCardProps, 'status' | 'exitCode'>, theme: Theme): string | undefined;
56
+ /** The status glyph, by the same ladder as the colour. */
57
+ export declare function serviceGlyph(props: Pick<ServiceCardProps, 'status' | 'exitCode'>, glyphs: ReturnType<typeof pickGlyphs>): string;
58
+ declare function ServiceCardImpl(props: ServiceCardProps): React.ReactElement;
59
+ /**
60
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
61
+ * `mapEntry` already preserves object identity for untouched entries, so the
62
+ * props below are stable references on a settled card, and `theme` / `caps` are
63
+ * `useMemo`d in `App.tsx` (I-L2-1).
64
+ */
65
+ export declare const ServiceCard: React.MemoExoticComponent<typeof ServiceCardImpl>;
66
+ export {};
@@ -0,0 +1,116 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * ServiceCard — one supervised background service in the transcript
4
+ * (background-service-supervision §3.8).
5
+ *
6
+ * ASCII ONLY: every glyph comes from `pickGlyphs`. `ui/` is inside
7
+ * `glyphs.test.ts::inScope`, so a literal here fails the scan.
8
+ *
9
+ * NO ANIMATED SPINNER, IN ANY STATE (D-14 / P2-7). That is STRICTER than
10
+ * `single-spinner-while-running` D-1, which suppresses animation only while the
11
+ * activity line is mounted — and a service can sit at `starting` while the agent
12
+ * is IDLE, where D-1's "one owner on screen" argument does not reach because
13
+ * there is no owner on screen. A card that animated only when the agent happened
14
+ * to be idle would be the worst of both. It uses `glyphs.spinnerStill`
15
+ * throughout and still accepts `reducedMotion` for consistency with
16
+ * `EntryView`'s comparator.
17
+ *
18
+ * TWO SHAPES, ONE COMPONENT. `terminal: true` is the one-row record a service's
19
+ * exit appends (D-11); everything else is the live card. They share a component
20
+ * because they share a colour ladder and a status vocabulary, and splitting them
21
+ * would be two places to keep those in step.
22
+ */
23
+ import React from 'react';
24
+ import { Box, Text } from 'ink';
25
+ import { pickGlyphs } from '../glyphs.js';
26
+ import { railBorderProps } from '../layout/Gutter.js';
27
+ import { formatDuration } from '../../agent/usage.js';
28
+ /** Tail rows the collapsed card draws. Mirrored as a NUMBER in `virtual-window.ts`. */
29
+ const COLLAPSED_TAIL_ROWS = 2;
30
+ /** Tail rows the expanded card draws — the snapshot's own cap. */
31
+ const EXPANDED_TAIL_ROWS = 8;
32
+ /**
33
+ * The card's colour.
34
+ *
35
+ * `running` is `toolRunning` rather than the accent: it is alive and listening
36
+ * on nothing, which is a real third state and not a lesser `ready`. A clean
37
+ * `exited(0)` is muted, because a command that finished on purpose is not a
38
+ * failure.
39
+ */
40
+ export function serviceCardColor(props, theme) {
41
+ switch (props.status) {
42
+ case 'starting':
43
+ return theme.muted;
44
+ case 'ready':
45
+ return theme.accent;
46
+ case 'running':
47
+ return theme.toolRunning;
48
+ case 'failed':
49
+ return theme.toolError;
50
+ case 'exited':
51
+ return props.exitCode === 0 ? theme.muted : theme.toolError;
52
+ case 'stopped':
53
+ return theme.muted;
54
+ default:
55
+ return theme.muted;
56
+ }
57
+ }
58
+ /** The status glyph, by the same ladder as the colour. */
59
+ export function serviceGlyph(props, glyphs) {
60
+ switch (props.status) {
61
+ case 'starting':
62
+ return glyphs.toolPending;
63
+ case 'ready':
64
+ return glyphs.toolDone;
65
+ case 'running':
66
+ return glyphs.toolRunning;
67
+ case 'failed':
68
+ return glyphs.toolError;
69
+ case 'exited':
70
+ return props.exitCode === 0 ? glyphs.toolDone : glyphs.toolError;
71
+ case 'stopped':
72
+ return glyphs.toolPending;
73
+ default:
74
+ return glyphs.toolPending;
75
+ }
76
+ }
77
+ /** The right-hand status clause: what happened, and how long it took. */
78
+ function statusClause(props, dot) {
79
+ const { status, exitCode, startedAt, readyAt, endedAt } = props;
80
+ if (status === 'ready') {
81
+ const took = readyAt === undefined ? '' : `${dot}${formatDuration(readyAt - startedAt)}`;
82
+ return `ready${took}`;
83
+ }
84
+ if (status === 'running')
85
+ return 'running';
86
+ if (status === 'starting')
87
+ return 'starting';
88
+ const lived = endedAt === undefined ? '' : `${dot}${formatDuration(endedAt - startedAt)}`;
89
+ if (status === 'stopped')
90
+ return `stopped${lived}`;
91
+ if (status === 'failed')
92
+ return `failed${lived}`;
93
+ return `exited${exitCode === null ? '' : ` code ${exitCode}`}${lived}`;
94
+ }
95
+ function ServiceCardImpl(props) {
96
+ const { serviceId, command, url, rows, expanded, terminal, theme, caps } = props;
97
+ const glyphs = pickGlyphs(caps);
98
+ const color = serviceCardColor(props, theme);
99
+ const dot = ` ${glyphs.midDot} `;
100
+ const clause = statusClause(props, dot);
101
+ // THE TERMINAL RECORD IS ONE ROW, ALWAYS. It is a SECOND event at a SECOND
102
+ // time — the only thing `<Static>` permits once the live card is printed — so
103
+ // it names what happened and nothing else (D-11).
104
+ if (terminal) {
105
+ return (_jsx(Box, { flexDirection: "row", children: _jsxs(Text, { color: color, wrap: "truncate", children: ["service ", serviceId, " ", clause, props.killIncomplete ? ' (may have left a detached child)' : ''] }) }));
106
+ }
107
+ const shown = rows.slice(-(expanded ? EXPANDED_TAIL_ROWS : COLLAPSED_TAIL_ROWS));
108
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: theme.accent, bold: true, children: ["service ", serviceId] }), _jsxs(Text, { color: theme.muted, wrap: "truncate", children: [' ', command] }), _jsxs(Text, { color: color, wrap: "truncate", children: [dot, clause] })] }), url !== undefined && (_jsxs(Box, { flexDirection: "row", flexShrink: 0, paddingLeft: 1, children: [_jsx(Text, { color: theme.primary, children: url }), _jsxs(Text, { color: theme.muted, children: [dot, "ctrl+c stop", dot, "ctrl+o log"] })] })), shown.length > 0 && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: shown.map((line, index) => (_jsx(Text, { color: theme.muted, wrap: "truncate", children: line }, index))) }))] }));
109
+ }
110
+ /**
111
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
112
+ * `mapEntry` already preserves object identity for untouched entries, so the
113
+ * props below are stable references on a settled card, and `theme` / `caps` are
114
+ * `useMemo`d in `App.tsx` (I-L2-1).
115
+ */
116
+ export const ServiceCard = React.memo(ServiceCardImpl);