@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,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);
@@ -3,15 +3,29 @@
3
3
  *
4
4
  * The role glyph, the gutter rail and the inter-entry spacing all belong to
5
5
  * `EntryFrame` now (§4.3), so this component renders content and nothing else.
6
- * That is also why it takes no `caps`: it has no glyph of its own left to
7
- * degrade.
6
+ *
7
+ * IT DOES TAKE `caps` AS OF tui-paste-handling (G7): the row cap below needs one
8
+ * glyph, and a hardcoded ellipsis is the exact failure `glyphs.ts` exists to
9
+ * prevent. That is the only reason the earlier "it has no glyph of its own left
10
+ * to degrade" note no longer holds.
8
11
  */
9
12
  import React from 'react';
10
13
  import type { Theme } from '../theme.js';
11
- declare function UserEntryImpl({ text, theme }: {
14
+ import type { TermCapabilities } from '../capabilities.js';
15
+ declare function UserEntryImpl({ text, theme, caps, }: {
12
16
  text: string;
13
17
  theme: Theme;
18
+ caps: TermCapabilities;
14
19
  }): React.ReactElement;
20
+ /**
21
+ * Rows this component renders for a given text.
22
+ *
23
+ * EXPORTED SO `virtual-window.ts` AND THIS FILE CANNOT DISAGREE (I-13). The
24
+ * render cap and the height estimate are ONE number; an over-estimated entry is
25
+ * never mounted, so it is never measured, so the estimate never self-corrects --
26
+ * which makes that particular disagreement permanent and silent.
27
+ */
28
+ export declare function userEntryRenderedRows(lineCount: number): number;
15
29
  /**
16
30
  * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
17
31
  * `mapEntry` already preserves object identity for untouched entries, so the
@@ -1,16 +1,45 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * Render a user message entry.
4
4
  *
5
5
  * The role glyph, the gutter rail and the inter-entry spacing all belong to
6
6
  * `EntryFrame` now (§4.3), so this component renders content and nothing else.
7
- * That is also why it takes no `caps`: it has no glyph of its own left to
8
- * degrade.
7
+ *
8
+ * IT DOES TAKE `caps` AS OF tui-paste-handling (G7): the row cap below needs one
9
+ * glyph, and a hardcoded ellipsis is the exact failure `glyphs.ts` exists to
10
+ * prevent. That is the only reason the earlier "it has no glyph of its own left
11
+ * to degrade" note no longer holds.
9
12
  */
10
13
  import React from 'react';
11
14
  import { Box, Text } from 'ink';
12
- function UserEntryImpl({ text, theme }) {
13
- return (_jsx(Box, { flexDirection: "column", children: text.split('\n').map((line, i) => (_jsx(Text, { color: theme.user, bold: i === 0, children: line }, i))) }));
15
+ import { pickGlyphs } from '../glyphs.js';
16
+ import { USER_ENTRY_MAX_ROWS } from '../composer-limits.js';
17
+ function UserEntryImpl({ text, theme, caps, }) {
18
+ const glyphs = pickGlyphs(caps);
19
+ const all = text.split('\n');
20
+ // A submitted 5 000-line paste otherwise asks Yoga to lay out 5 000 nodes
21
+ // inside a measured entry -- the hazard `render-cache.ts:37` already records
22
+ // in the same words ("a multi-megabyte 'code block' is almost always a pasted
23
+ // log"). The full text stays in `ViewState` and in the exit transcript, which
24
+ // is deliberately NOT capped: a replay a user pipes into an issue must be
25
+ // complete.
26
+ const hidden = Math.max(0, all.length - USER_ENTRY_MAX_ROWS);
27
+ const shown = hidden > 0 ? all.slice(0, USER_ENTRY_MAX_ROWS) : all;
28
+ return (_jsxs(Box, { flexDirection: "column", children: [shown.map((line, i) => (_jsx(Text, { color: theme.user, bold: i === 0, children: line }, i))), hidden > 0 && (
29
+ // The tail NAMES the number of hidden rows rather than hiding them
30
+ // silently, which is what makes the cap honest (D-9 / R-10).
31
+ _jsx(Text, { color: theme.muted, children: `${glyphs.ellipsis} +${hidden} more lines` }))] }));
32
+ }
33
+ /**
34
+ * Rows this component renders for a given text.
35
+ *
36
+ * EXPORTED SO `virtual-window.ts` AND THIS FILE CANNOT DISAGREE (I-13). The
37
+ * render cap and the height estimate are ONE number; an over-estimated entry is
38
+ * never mounted, so it is never measured, so the estimate never self-corrects --
39
+ * which makes that particular disagreement permanent and silent.
40
+ */
41
+ export function userEntryRenderedRows(lineCount) {
42
+ return lineCount > USER_ENTRY_MAX_ROWS ? USER_ENTRY_MAX_ROWS + 1 : lineCount;
14
43
  }
15
44
  /**
16
45
  * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
@@ -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: '+',