@aragon-agent/cli 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +202 -0
  3. package/dist/agent/controller.d.ts +74 -1
  4. package/dist/agent/controller.js +200 -1
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +120 -0
  8. package/dist/agent/reducer.js +201 -2
  9. package/dist/cli.js +224 -5
  10. package/dist/commands/builtins.js +73 -23
  11. package/dist/commands/registry.d.ts +27 -0
  12. package/dist/compaction/archive.d.ts +175 -0
  13. package/dist/compaction/archive.js +267 -0
  14. package/dist/compaction/child.d.ts +97 -0
  15. package/dist/compaction/child.js +113 -0
  16. package/dist/compaction/command.d.ts +41 -0
  17. package/dist/compaction/command.js +342 -0
  18. package/dist/compaction/compactor.d.ts +268 -0
  19. package/dist/compaction/compactor.js +689 -0
  20. package/dist/compaction/digest.d.ts +62 -0
  21. package/dist/compaction/digest.js +164 -0
  22. package/dist/compaction/limits.d.ts +165 -0
  23. package/dist/compaction/limits.js +172 -0
  24. package/dist/compaction/pressure.d.ts +135 -0
  25. package/dist/compaction/pressure.js +167 -0
  26. package/dist/compaction/summarize-call.d.ts +70 -0
  27. package/dist/compaction/summarize-call.js +86 -0
  28. package/dist/compaction/summary-prompt.d.ts +85 -0
  29. package/dist/compaction/summary-prompt.js +197 -0
  30. package/dist/compaction/tail-budget.d.ts +52 -0
  31. package/dist/compaction/tail-budget.js +67 -0
  32. package/dist/compaction/types.d.ts +179 -0
  33. package/dist/compaction/types.js +22 -0
  34. package/dist/compaction/wiring.d.ts +255 -0
  35. package/dist/compaction/wiring.js +556 -0
  36. package/dist/config/app-paths.d.ts +14 -0
  37. package/dist/config/app-paths.js +16 -0
  38. package/dist/config/cli-commands.d.ts +41 -0
  39. package/dist/config/cli-commands.js +77 -1
  40. package/dist/config/env.d.ts +4 -1
  41. package/dist/config/env.js +92 -2
  42. package/dist/config/load.d.ts +31 -0
  43. package/dist/config/load.js +63 -1
  44. package/dist/config/schema.d.ts +159 -0
  45. package/dist/config/schema.js +137 -0
  46. package/dist/config/store.js +17 -1
  47. package/dist/config/ui-state.d.ts +28 -1
  48. package/dist/config/ui-state.js +9 -0
  49. package/dist/exec/events.d.ts +50 -1
  50. package/dist/exec/runner.js +42 -0
  51. package/dist/input/mouse-events.d.ts +31 -5
  52. package/dist/input/mouse-events.js +86 -27
  53. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  54. package/dist/logging/install.d.ts +51 -1
  55. package/dist/logging/install.js +28 -0
  56. package/dist/logging/logger.d.ts +1 -1
  57. package/dist/session/persist.js +19 -0
  58. package/dist/team/report.js +7 -1
  59. package/dist/team/runtime.d.ts +10 -0
  60. package/dist/team/runtime.js +7 -0
  61. package/dist/team/subagent.d.ts +31 -1
  62. package/dist/team/subagent.js +42 -15
  63. package/dist/team/types.d.ts +8 -0
  64. package/dist/ui/ActivityLine.d.ts +19 -1
  65. package/dist/ui/ActivityLine.js +10 -2
  66. package/dist/ui/App.d.ts +48 -1
  67. package/dist/ui/App.js +294 -13
  68. package/dist/ui/Composer.d.ts +13 -1
  69. package/dist/ui/Composer.js +2 -2
  70. package/dist/ui/PromptInput.d.ts +34 -1
  71. package/dist/ui/PromptInput.js +54 -3
  72. package/dist/ui/StatusBar.d.ts +40 -0
  73. package/dist/ui/StatusBar.js +13 -4
  74. package/dist/ui/TeamPanel.js +7 -1
  75. package/dist/ui/Transcript.d.ts +21 -1
  76. package/dist/ui/Transcript.js +32 -2
  77. package/dist/ui/clipboard.d.ts +52 -0
  78. package/dist/ui/clipboard.js +91 -0
  79. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  80. package/dist/ui/entries/CompactionCard.js +158 -0
  81. package/dist/ui/frame-differ.d.ts +46 -0
  82. package/dist/ui/frame-differ.js +94 -10
  83. package/dist/ui/gauge.d.ts +16 -1
  84. package/dist/ui/gauge.js +25 -6
  85. package/dist/ui/glyphs.d.ts +11 -0
  86. package/dist/ui/glyphs.js +2 -0
  87. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  88. package/dist/ui/layout/ScrollViewport.js +128 -21
  89. package/dist/ui/layout/budget.d.ts +10 -3
  90. package/dist/ui/layout/budget.js +10 -3
  91. package/dist/ui/layout/follow-state.d.ts +137 -0
  92. package/dist/ui/layout/follow-state.js +150 -0
  93. package/dist/ui/layout/virtual-window.d.ts +11 -0
  94. package/dist/ui/layout/virtual-window.js +34 -0
  95. package/dist/ui/overlays/HelpOverlay.js +6 -1
  96. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  97. package/dist/ui/overlays/SettingsScreen.js +60 -1
  98. package/dist/ui/palettes.d.ts +16 -0
  99. package/dist/ui/palettes.js +6 -0
  100. package/dist/ui/screen.d.ts +22 -0
  101. package/dist/ui/screen.js +70 -8
  102. package/dist/ui/selection/highlight.d.ts +39 -0
  103. package/dist/ui/selection/highlight.js +64 -0
  104. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  105. package/dist/ui/selection/screen-mirror.js +35 -0
  106. package/dist/ui/selection/selection-controller.d.ts +88 -0
  107. package/dist/ui/selection/selection-controller.js +282 -0
  108. package/dist/ui/selection/selection.d.ts +90 -0
  109. package/dist/ui/selection/selection.js +223 -0
  110. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  111. package/dist/ui/stdout-frame-writer.js +15 -0
  112. package/dist/ui/theme.d.ts +11 -0
  113. package/dist/ui/theme.js +2 -0
  114. package/dist/ui/transcript-text.js +23 -0
  115. package/dist/ui/use-startup-notices.d.ts +27 -1
  116. package/dist/ui/use-startup-notices.js +43 -11
  117. package/dist/ui/use-wheel-routing.js +8 -0
  118. package/package.json +4 -2
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import React from 'react';
11
11
  import { type ThinkingLevel } from '@aragon-agent/core';
12
- import { type FastConfig } from '../../config/schema.js';
12
+ import { type CompactionConfig, type FastConfig } from '../../config/schema.js';
13
13
  import { type LogLevelName } from '../../logging/levels.js';
14
14
  import type { Theme } from '../theme.js';
15
15
  import type { TermCapabilities } from '../capabilities.js';
@@ -57,6 +57,33 @@ export interface SettingsValues {
57
57
  fastReview: string;
58
58
  /** The session review budget. `fastReview` is the PACE, this is the TOTAL. */
59
59
  fastReviewBudget: string;
60
+ /**
61
+ * Context compaction (context-auto-compaction §4.5).
62
+ *
63
+ * THREE ROWS, NOT SIX. `onFailure` and `useFastTier` are file- and
64
+ * `/compact`-only on purpose: this screen is for the settings a user CHANGES,
65
+ * not for every setting that exists, and "what should it do if the summarizer
66
+ * 500s" is not a question a user reaches for with arrow keys.
67
+ *
68
+ * `string` rather than their config types because this screen edits TEXT, and
69
+ * a typo must change NOTHING rather than resolve to a default — the discipline
70
+ * `maxTokens` and `fastReview` already follow here.
71
+ */
72
+ compactionEnabled: 'on' | 'off';
73
+ /** A percentage the user types, e.g. `90`. Parsed on save; a typo is ignored. */
74
+ compactionThreshold: string;
75
+ compactionKeepTurns: string;
76
+ /**
77
+ * The two hardening toggles (context-auto-compaction-hardening §4.6).
78
+ *
79
+ * FIVE ROWS NOW, NOT THREE, and these two earn their place where `onFailure`
80
+ * did not: each one names a THING THE SESSION WILL DO - spend money inside a
81
+ * fan-out, and write conversation content to disk - rather than a policy for a
82
+ * failure the user has never seen. Both are questions a user reaches for
83
+ * deliberately.
84
+ */
85
+ compactionSubagents: 'on' | 'off';
86
+ compactionArchive: 'on' | 'off';
60
87
  }
61
88
  interface SettingsScreenProps {
62
89
  initial: SettingsValues;
@@ -121,6 +148,25 @@ export declare function readFastSettings(values: Pick<SettingsValues, 'fastEnabl
121
148
  * save writes the clamp's default over whatever the user had.
122
149
  */
123
150
  export declare function fastSettingsFrom(fast: FastConfig): Pick<SettingsValues, 'fastEnabled' | 'fastModel' | 'fastProvider' | 'fastReview' | 'fastReviewBudget'>;
151
+ /**
152
+ * Read the three compaction rows back into a config patch.
153
+ *
154
+ * A TYPO CHANGES NOTHING — the discipline `readFastSettings` states one function
155
+ * up, and the reason is the same: a threshold that silently resolved to the
156
+ * default on a mistyped keystroke would be a setting the user believes they
157
+ * changed and did not. `parseThresholdInput` returns `null` for anything that is
158
+ * not a ratio or a percentage, and the key is then simply absent from the patch.
159
+ */
160
+ export declare function readCompactionSettings(values: Pick<SettingsValues, 'compactionEnabled' | 'compactionThreshold' | 'compactionKeepTurns' | 'compactionSubagents' | 'compactionArchive'>): Partial<CompactionConfig>;
161
+ /**
162
+ * The inverse: seed the three rows from a resolved config.
163
+ *
164
+ * OMITTING A KEY HERE IS THE SILENT HALF OF ADDING A ROW, exactly as it is for
165
+ * the fast rows above: the row renders empty, `readCompactionSettings` sees an
166
+ * unparseable value, and an untouched save writes the clamp's default over
167
+ * whatever the user had.
168
+ */
169
+ export declare function compactionSettingsFrom(compaction: CompactionConfig): Pick<SettingsValues, 'compactionEnabled' | 'compactionThreshold' | 'compactionKeepTurns' | 'compactionSubagents' | 'compactionArchive'>;
124
170
  /**
125
171
  * The cap this setting will ACTUALLY produce against the selected model.
126
172
  *
@@ -11,13 +11,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
11
11
  import { useState } from 'react';
12
12
  import { Text, useInput } from 'ink';
13
13
  import { DEFAULT_MAX_OUTPUT_TOKENS, resolveOutputTokens, } from '@aragon-agent/core';
14
- import { ADAPTER_PROVIDERS, THINKING_LEVELS, maskSecret, parseMaxTokensInput, } from '../../config/schema.js';
14
+ import { ADAPTER_PROVIDERS, THINKING_LEVELS, maskSecret, parseMaxTokensInput, parseThresholdInput, } from '../../config/schema.js';
15
15
  import { LOG_LEVEL_NAMES } from '../../logging/levels.js';
16
16
  import { pickGlyphs } from '../glyphs.js';
17
17
  import { OverlayFrame } from '../layout/OverlayFrame.js';
18
18
  const SHOW_THINKING_OPTIONS = ['off', 'on'];
19
19
  const LIVE_OUTPUT_OPTIONS = ['off', 'on'];
20
20
  const FAST_ENABLED_OPTIONS = ['off', 'on'];
21
+ const COMPACTION_ENABLED_OPTIONS = ['off', 'on'];
21
22
  /**
22
23
  * `(inherit)` IS THE EMPTY STRING, rendered as a word.
23
24
  *
@@ -107,6 +108,48 @@ export function fastSettingsFrom(fast) {
107
108
  fastReviewBudget: String(fast.reviewMaxPerSession),
108
109
  };
109
110
  }
111
+ /**
112
+ * Read the three compaction rows back into a config patch.
113
+ *
114
+ * A TYPO CHANGES NOTHING — the discipline `readFastSettings` states one function
115
+ * up, and the reason is the same: a threshold that silently resolved to the
116
+ * default on a mistyped keystroke would be a setting the user believes they
117
+ * changed and did not. `parseThresholdInput` returns `null` for anything that is
118
+ * not a ratio or a percentage, and the key is then simply absent from the patch.
119
+ */
120
+ export function readCompactionSettings(values) {
121
+ const threshold = parseThresholdInput(values.compactionThreshold);
122
+ const keep = values.compactionKeepTurns.trim();
123
+ const parsedKeep = /^\d+$/.test(keep) ? Number.parseInt(keep, 10) : Number.NaN;
124
+ return {
125
+ enabled: values.compactionEnabled === 'on',
126
+ ...(threshold !== null ? { threshold } : {}),
127
+ ...(Number.isFinite(parsedKeep) && parsedKeep > 0 ? { keepRecentTurns: parsedKeep } : {}),
128
+ // AN ENUM ROW CANNOT BE MISTYPED, so unlike the two text rows above these are
129
+ // written unconditionally.
130
+ subagents: values.compactionSubagents === 'on',
131
+ archive: values.compactionArchive === 'on',
132
+ };
133
+ }
134
+ /**
135
+ * The inverse: seed the three rows from a resolved config.
136
+ *
137
+ * OMITTING A KEY HERE IS THE SILENT HALF OF ADDING A ROW, exactly as it is for
138
+ * the fast rows above: the row renders empty, `readCompactionSettings` sees an
139
+ * unparseable value, and an untouched save writes the clamp's default over
140
+ * whatever the user had.
141
+ */
142
+ export function compactionSettingsFrom(compaction) {
143
+ return {
144
+ compactionEnabled: compaction.enabled ? 'on' : 'off',
145
+ // A PERCENTAGE, because that is the unit the status bar shows and the unit a
146
+ // user thinks in. `parseThresholdInput` accepts it back.
147
+ compactionThreshold: String(Math.round(compaction.threshold * 100)),
148
+ compactionKeepTurns: String(compaction.keepRecentTurns),
149
+ compactionSubagents: compaction.subagents ? 'on' : 'off',
150
+ compactionArchive: compaction.archive ? 'on' : 'off',
151
+ };
152
+ }
110
153
  const FIELDS = [
111
154
  { key: 'provider', label: 'Provider', kind: 'enum', options: ADAPTER_PROVIDERS },
112
155
  { key: 'model', label: 'Model', kind: 'text' },
@@ -146,6 +189,22 @@ const FIELDS = [
146
189
  { key: 'fastProvider', label: 'Fast provider', kind: 'enum', options: FAST_PROVIDER_OPTIONS },
147
190
  { key: 'fastReview', label: 'Fast review', kind: 'text' },
148
191
  { key: 'fastReviewBudget', label: 'Fast budget', kind: 'text' },
192
+ // Context compaction sits after the fast rows and still BEFORE `API key`, for
193
+ // the reason recorded above that row: the key is the one a user scrolls to
194
+ // deliberately, and burying it further would be worse than the ordering is
195
+ // better. Three rows, and every label is inside the 18-column budget
196
+ // `.padEnd(18)` enforces below.
197
+ //
198
+ // `Compact at` is a PERCENTAGE and carries no `(50-95)` hint, for the reason
199
+ // `Fast budget` records: no other bounded row carries its range either,
200
+ // `clampCompactionConfig` is the enforcement, and `/compact threshold` states
201
+ // the range in the one place a user meets it — when it refuses a bad one.
202
+ { key: 'compactionEnabled', label: 'Auto-compaction', kind: 'enum', options: COMPACTION_ENABLED_OPTIONS },
203
+ { key: 'compactionThreshold', label: 'Compact at %', kind: 'text' },
204
+ { key: 'compactionKeepTurns', label: 'Keep turns', kind: 'text' },
205
+ // Both labels are inside the 18-column budget `.padEnd(18)` enforces below.
206
+ { key: 'compactionSubagents', label: 'Compact children', kind: 'enum', options: COMPACTION_ENABLED_OPTIONS },
207
+ { key: 'compactionArchive', label: 'Archive dropped', kind: 'enum', options: COMPACTION_ENABLED_OPTIONS },
149
208
  { key: 'apiKey', label: 'API key', kind: 'secret' },
150
209
  { key: 'logLevel', label: 'Log level', kind: 'enum', options: LOG_LEVEL_NAMES },
151
210
  ];
@@ -32,6 +32,22 @@ export interface Palette {
32
32
  idleBorder: string;
33
33
  hintFg: string;
34
34
  logoShadow: string;
35
+ /**
36
+ * Drag-selection highlight (tui-selection-and-scroll-follow §4.4.4).
37
+ *
38
+ * A PAIR, not a background alone: the selected run has its inner SGR stripped
39
+ * before it is repainted, so whatever foreground the row had is gone by the
40
+ * time the highlight is applied and a background on its own would leave the
41
+ * text at the terminal's default colour over an arbitrary hue. The two are
42
+ * contrast-pinned against EACH OTHER in `palettes.test.ts` — the surface a
43
+ * selected glyph sits on is `selectionBg`, not `DARK_SURFACE`.
44
+ *
45
+ * Below ansi-256 these never reach the terminal at all: `highlight.ts` emits
46
+ * plain reverse video there, because `getTheme` has degraded them to chalk
47
+ * colour NAMES by then and a name is not an escape sequence.
48
+ */
49
+ selectionBg: string;
50
+ selectionFg: string;
35
51
  gradient: [string, string, string];
36
52
  gauge: {
37
53
  track: string;
@@ -39,6 +39,8 @@ export const WARM = {
39
39
  idleBorder: '#4a423b',
40
40
  hintFg: '#9c9086',
41
41
  logoShadow: '#4a423b',
42
+ selectionBg: '#e0b088',
43
+ selectionFg: '#1c1917',
42
44
  gradient: ['#d08c60', '#c9a227', '#b0916a'],
43
45
  gauge: { track: '#4a423b', low: '#7fb08a', mid: '#c9a227', high: '#cf6b5c' },
44
46
  diff: { add: '#7fb08a', remove: '#cf6b5c', meta: '#d08c60', context: '#a2968a' },
@@ -66,6 +68,8 @@ export const COOL = {
66
68
  idleBorder: '#3b4261',
67
69
  hintFg: '#565f89',
68
70
  logoShadow: '#3b4261',
71
+ selectionBg: '#7aa2f7',
72
+ selectionFg: '#1a1b26',
69
73
  gradient: ['#7aa2f7', '#bb9af7', '#7dcfff'],
70
74
  gauge: { track: '#3b4261', low: '#9ece6a', mid: '#e0af68', high: '#f7768e' },
71
75
  diff: { add: '#9ece6a', remove: '#f7768e', meta: '#7aa2f7', context: '#7a86b8' },
@@ -92,6 +96,8 @@ export const LIGHT = {
92
96
  idleBorder: '#c8cdd6',
93
97
  hintFg: '#6b7280',
94
98
  logoShadow: '#c8cdd6',
99
+ selectionBg: '#2959aa',
100
+ selectionFg: '#ffffff',
95
101
  gradient: ['#2959aa', '#8c4bc9', '#0f7490'],
96
102
  gauge: { track: '#c8cdd6', low: '#2e7d32', mid: '#b5730f', high: '#c62828' },
97
103
  diff: { add: '#2e7d32', remove: '#c62828', meta: '#2959aa', context: '#6b7280' },
@@ -14,6 +14,16 @@
14
14
  export interface ScreenHandle {
15
15
  /** Leave the alternate screen. Idempotent — safe from every exit path. */
16
16
  restore(): void;
17
+ /**
18
+ * Hand the mouse back to the terminal for this session, or take it again
19
+ * (tui-selection-and-scroll-follow §4.4.6 / G3). A no-op when this session
20
+ * never had a filter, and idempotent in both directions.
21
+ *
22
+ * This is the honest answer to "a user who wants their terminal's own
23
+ * selection", and it is reversible in one command instead of a restart plus a
24
+ * config edit (D-9).
25
+ */
26
+ setMouseCapture(on: boolean): void;
17
27
  }
18
28
  export interface AltScreenOptions {
19
29
  /**
@@ -27,6 +37,18 @@ export interface AltScreenOptions {
27
37
  * only caller is `cli.tsx::runInteractive`, and it passes `filter !== null`.
28
38
  */
29
39
  readonly mouse: boolean;
40
+ /**
41
+ * Whether to request motion reporting (`?1002h`) alongside the two modes
42
+ * above — i.e. whether drag-select is on for this session.
43
+ *
44
+ * SAME "ALREADY DECIDED ELSEWHERE" DISCIPLINE AS `mouse`: this module must
45
+ * never derive it from config. `cli.tsx` passes
46
+ * `mouseFilter !== null && cfg.mouseSelect`.
47
+ *
48
+ * Optional, defaulting to `false`, so every pre-existing caller and every
49
+ * pre-existing test emits exactly the bytes it emitted before (AC-8 / T-34).
50
+ */
51
+ readonly motion?: boolean;
30
52
  }
31
53
  /**
32
54
  * Enter the alternate screen. A non-TTY stdout is a hard no-op (tests, pipes,
package/dist/ui/screen.js CHANGED
@@ -20,12 +20,24 @@ const SHOW_CURSOR = '\x1b[?25h';
20
20
  /**
21
21
  * `?1000` is NORMAL TRACKING (press / release), the minimum mode that reports
22
22
  * wheel buttons; `?1006` selects SGR encoding so coordinates arrive as decimal
23
- * text rather than raw bytes above 0x7F that `setEncoding('utf8')` would
24
- * mangle. Motion tracking (`?1002` / `?1003`) is deliberately NOT enabled: it
25
- * floods stdin on every pointer move and buys nothing this feature uses.
23
+ * text rather than raw bytes above 0x7F that `setEncoding('utf8')` would mangle.
24
+ *
25
+ * `?1002` is BUTTON-EVENT TRACKING: motion is reported ONLY WHILE A BUTTON IS
26
+ * HELD, which is what drag-select needs and costs exactly zero reports while the
27
+ * pointer is idle. This comment used to lump it together with `?1003` and
28
+ * dismiss both as "floods stdin on every pointer move" — true of `?1003`
29
+ * (any-event), false of `?1002`, and the sentence had to go in the same commit
30
+ * that started asking for it or it would stand as a documented reason not to
31
+ * (AC-11 / D-10).
32
+ *
33
+ * IT IS GATED ON `motion`, i.e. on the resolved `mouseSelect` capability, and
34
+ * that is what makes "S3 is revertible" a byte-level fact rather than a claim:
35
+ * with drag-select off the sequences below are byte-identical to the constants
36
+ * they replaced (AC-8 / D-15). `disableMouse` is the exact reverse of
37
+ * `enableMouse` for both values (I-1 / AC-7).
26
38
  */
27
- const ENABLE_MOUSE = '\x1b[?1000h\x1b[?1006h';
28
- const DISABLE_MOUSE = '\x1b[?1006l\x1b[?1000l';
39
+ const enableMouse = (motion) => `\x1b[?1000h${motion ? '\x1b[?1002h' : ''}\x1b[?1006h`;
40
+ const disableMouse = (motion) => `\x1b[?1006l${motion ? '\x1b[?1002l' : ''}\x1b[?1000l`;
29
41
  /**
30
42
  * Alternate scroll (DEC private mode 1007) is what turns a wheel notch into a
31
43
  * burst of arrow keys, which `PromptInput` reads as prompt-history recall. With
@@ -43,7 +55,7 @@ const DISABLE_MOUSE = '\x1b[?1006l\x1b[?1000l';
43
55
  */
44
56
  const SAVE_ALT_SCROLL_OFF = '\x1b[?1007s\x1b[?1007l';
45
57
  const RESTORE_ALT_SCROLL = '\x1b[?1007r';
46
- const NOOP_HANDLE = { restore() { } };
58
+ const NOOP_HANDLE = { restore() { }, setMouseCapture() { } };
47
59
  function safeWrite(stdout, data) {
48
60
  try {
49
61
  stdout.write(data);
@@ -60,7 +72,50 @@ export function enterAltScreen(stdout, options = { mouse: false }) {
60
72
  if (!stdout || !stdout.isTTY || typeof stdout.write !== 'function')
61
73
  return NOOP_HANDLE;
62
74
  const mouse = options.mouse === true;
63
- safeWrite(stdout, ENTER_ALT + CLEAR_VISIBLE + CURSOR_HOME + (mouse ? ENABLE_MOUSE : SAVE_ALT_SCROLL_OFF));
75
+ const motion = options.motion === true;
76
+ safeWrite(stdout, ENTER_ALT + CLEAR_VISIBLE + CURSOR_HOME + (mouse ? enableMouse(motion) : SAVE_ALT_SCROLL_OFF));
77
+ /**
78
+ * THE CURRENT capture state, not the one the session started in (I-10).
79
+ *
80
+ * `restore()` used to close over the immutable `mouse` boolean. After
81
+ * `/mouse` exists that boolean is no longer the truth, and the exit path is
82
+ * the one place where being wrong leaves the user's shell broken until they
83
+ * run `reset`.
84
+ */
85
+ let captured = mouse;
86
+ /**
87
+ * Whether OUR `XTSAVE` of DEC 1007 is outstanding.
88
+ *
89
+ * `XTSAVE` HAS ONE SLOT PER MODE, so the save and the restore must stay
90
+ * STRICTLY PAIRED: an `off` that saved while our own disable was already in
91
+ * effect would save OUR value, and the following restore would hand the user
92
+ * back a preference that was never theirs — permanently.
93
+ */
94
+ let savedAltScroll = mouse ? false : true;
95
+ const setMouseCapture = (on) => {
96
+ // A session that never installed a filter has nothing to release, and
97
+ // enabling reporting without one types `[<0;12;5M` into the user's message
98
+ // on every click (I-8). It stays a no-op in both directions.
99
+ if (!mouse || on === captured)
100
+ return;
101
+ captured = on;
102
+ if (on) {
103
+ // Alternate scroll goes back to the user's own preference BEFORE reporting
104
+ // resumes, so the two never overlap in the "both disabled" state.
105
+ safeWrite(stdout, (savedAltScroll ? RESTORE_ALT_SCROLL : '') + enableMouse(motion));
106
+ savedAltScroll = false;
107
+ return;
108
+ }
109
+ // DEC 1007 IS THE HALF THAT MUST NOT BE DROPPED (P1-1). With reporting off,
110
+ // alternate scroll turns every wheel notch back into a burst of arrow keys,
111
+ // which `PromptInput` reads as prompt-history recall — replacing the user's
112
+ // draft with an old prompt. A `/mouse off` that wrote only the mouse
113
+ // sequences would hand that defect back on request, in the one command whose
114
+ // entire purpose is to make things better. Saved rather than force-set, so a
115
+ // user who deliberately disabled alternate scroll keeps their choice.
116
+ safeWrite(stdout, disableMouse(motion) + SAVE_ALT_SCROLL_OFF);
117
+ savedAltScroll = true;
118
+ };
64
119
  let restored = false;
65
120
  return {
66
121
  restore() {
@@ -74,8 +129,15 @@ export function enterAltScreen(stdout, options = { mouse: false }) {
74
129
  // here and not in `App.tsx`, a component that is on none of them.
75
130
  // Leaving reporting on prints `[<0;12;5M` in the user's shell on every
76
131
  // click, forever, until they run `reset`.
77
- safeWrite(stdout, (mouse ? DISABLE_MOUSE : RESTORE_ALT_SCROLL) + SHOW_CURSOR + LEAVE_ALT);
132
+ //
133
+ // IT UNWINDS THE CURRENT STATE (I-10): `captured` and `savedAltScroll` are
134
+ // read here rather than the startup `mouse` flag, so a session that ran
135
+ // `/mouse off` exits with exactly one outstanding save restored and
136
+ // nothing left reporting.
137
+ const tail = (captured ? disableMouse(motion) : '') + (savedAltScroll ? RESTORE_ALT_SCROLL : '');
138
+ safeWrite(stdout, tail + SHOW_CURSOR + LEAVE_ALT);
78
139
  },
140
+ setMouseCapture,
79
141
  };
80
142
  }
81
143
  /**
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The row painter (tui-selection-and-scroll-follow §4.4.4). PURE — strings in,
3
+ * strings out.
4
+ *
5
+ * It runs inside the frame differ's `decorate` hook rather than in React (D-6):
6
+ * a re-render per drag event goes through the render governor and the whole
7
+ * component tree, while this repaints one to three rows with no commit at all.
8
+ */
9
+ import type { Theme } from '../theme.js';
10
+ import type { TermCapabilities } from '../capabilities.js';
11
+ /**
12
+ * The SGR sequence that opens a highlighted run.
13
+ *
14
+ * TRUECOLOR OR REVERSE VIDEO, WITH NOTHING IN BETWEEN, and that is deliberate.
15
+ * `getTheme` degrades every palette colour to the terminal's depth, so below
16
+ * ansi-256 `theme.selectionBg` is a CHALK COLOUR NAME (`'blue'`) rather than a
17
+ * hex triple — and a name cannot be turned into an escape sequence here without
18
+ * duplicating chalk's own table. Reverse video is what a terminal that shallow
19
+ * would render anyway, it needs no colour support at all, and it is exactly what
20
+ * the emulator's own selection looks like. The `HEX` test is therefore both the
21
+ * colour-depth check and the type check.
22
+ */
23
+ export declare function selectionOpen(theme: Theme, caps: TermCapabilities): string;
24
+ /**
25
+ * Paint `[from, to)` of one frame line.
26
+ *
27
+ * `to` is already clamped to the terminal width by the caller, so `Infinity`
28
+ * never reaches here.
29
+ *
30
+ * STRIPPING SGR INSIDE THE SELECTION IS NOT LAZINESS. An inner `\x1b[0m` — which
31
+ * `Markdown` and `cli-highlight` emit constantly — would cancel the highlight
32
+ * attribute mid-run and leave holes in it. Terminals repaint selected text in
33
+ * the selection's own colours for exactly this reason.
34
+ *
35
+ * `pad` is what gives a multi-row selection a straight right edge over short
36
+ * lines, and it measures with `columnsOf` — the same oracle `sliceColumns`
37
+ * charges with — rather than reaching for a third width function (I-12).
38
+ */
39
+ export declare function paintRow(line: string, from: number, to: number, open: string): string;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The row painter (tui-selection-and-scroll-follow §4.4.4). PURE — strings in,
3
+ * strings out.
4
+ *
5
+ * It runs inside the frame differ's `decorate` hook rather than in React (D-6):
6
+ * a re-render per drag event goes through the render governor and the whole
7
+ * component tree, while this repaints one to three rows with no commit at all.
8
+ */
9
+ import stripAnsi from 'strip-ansi';
10
+ import { columnsOf, sliceColumns } from './selection.js';
11
+ const CSI = '\x1b[';
12
+ const SGR_RESET = `${CSI}0m`;
13
+ /** Reverse video — the universal fallback, and what a monochrome terminal gets. */
14
+ const REVERSE = `${CSI}7m`;
15
+ const HEX = /^#[0-9a-f]{6}$/i;
16
+ function rgb(hex) {
17
+ const n = Number.parseInt(hex.slice(1), 16);
18
+ return `${(n >> 16) & 0xff};${(n >> 8) & 0xff};${n & 0xff}`;
19
+ }
20
+ /**
21
+ * The SGR sequence that opens a highlighted run.
22
+ *
23
+ * TRUECOLOR OR REVERSE VIDEO, WITH NOTHING IN BETWEEN, and that is deliberate.
24
+ * `getTheme` degrades every palette colour to the terminal's depth, so below
25
+ * ansi-256 `theme.selectionBg` is a CHALK COLOUR NAME (`'blue'`) rather than a
26
+ * hex triple — and a name cannot be turned into an escape sequence here without
27
+ * duplicating chalk's own table. Reverse video is what a terminal that shallow
28
+ * would render anyway, it needs no colour support at all, and it is exactly what
29
+ * the emulator's own selection looks like. The `HEX` test is therefore both the
30
+ * colour-depth check and the type check.
31
+ */
32
+ export function selectionOpen(theme, caps) {
33
+ if (caps.colorLevel < 2)
34
+ return REVERSE;
35
+ const bg = theme.selectionBg;
36
+ const fg = theme.selectionFg;
37
+ if (!bg || !fg || !HEX.test(bg) || !HEX.test(fg))
38
+ return REVERSE;
39
+ return `${CSI}48;2;${rgb(bg)}m${CSI}38;2;${rgb(fg)}m`;
40
+ }
41
+ /**
42
+ * Paint `[from, to)` of one frame line.
43
+ *
44
+ * `to` is already clamped to the terminal width by the caller, so `Infinity`
45
+ * never reaches here.
46
+ *
47
+ * STRIPPING SGR INSIDE THE SELECTION IS NOT LAZINESS. An inner `\x1b[0m` — which
48
+ * `Markdown` and `cli-highlight` emit constantly — would cancel the highlight
49
+ * attribute mid-run and leave holes in it. Terminals repaint selected text in
50
+ * the selection's own colours for exactly this reason.
51
+ *
52
+ * `pad` is what gives a multi-row selection a straight right edge over short
53
+ * lines, and it measures with `columnsOf` — the same oracle `sliceColumns`
54
+ * charges with — rather than reaching for a third width function (I-12).
55
+ */
56
+ export function paintRow(line, from, to, open) {
57
+ if (!(to > from))
58
+ return line;
59
+ const head = sliceColumns(line, 0, from);
60
+ const mid = stripAnsi(sliceColumns(line, from, to));
61
+ const pad = ' '.repeat(Math.max(0, to - from - columnsOf(mid)));
62
+ const tail = sliceColumns(line, to);
63
+ return `${head}${open}${mid}${pad}${SGR_RESET}${tail}`;
64
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The screen mirror (tui-selection-and-scroll-follow §7) — the single source of
3
+ * truth for what text is on which row.
4
+ *
5
+ * I-4 RIDES ON THIS OBJECT. The highlight and the clipboard both read the rows
6
+ * stored here, written by the same `decorate` call, so whatever changes under a
7
+ * selection is simultaneously what is highlighted and what would be copied. What
8
+ * you see is what you get BY CONSTRUCTION, rather than by a staleness check that
9
+ * can be wrong.
10
+ *
11
+ * `plain` is computed LAZILY. `decorate` runs on every frame Ink produces —
12
+ * 15-30 times a second during a run — while a copy happens when a human releases
13
+ * a mouse button, so stripping every row eagerly would spend H regex passes per
14
+ * frame to serve an event that may never come. The cache is dropped by `set`, so
15
+ * the laziness is invisible to callers and cannot go stale.
16
+ */
17
+ export interface ScreenMirror {
18
+ /** Last frame, ANSI intact — what `decorate` paints over. */
19
+ readonly raw: readonly string[];
20
+ /** The same rows, ANSI stripped — what `selectedText` reads. */
21
+ readonly plain: readonly string[];
22
+ set(lines: readonly string[]): void;
23
+ }
24
+ export declare function createScreenMirror(): ScreenMirror;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The screen mirror (tui-selection-and-scroll-follow §7) — the single source of
3
+ * truth for what text is on which row.
4
+ *
5
+ * I-4 RIDES ON THIS OBJECT. The highlight and the clipboard both read the rows
6
+ * stored here, written by the same `decorate` call, so whatever changes under a
7
+ * selection is simultaneously what is highlighted and what would be copied. What
8
+ * you see is what you get BY CONSTRUCTION, rather than by a staleness check that
9
+ * can be wrong.
10
+ *
11
+ * `plain` is computed LAZILY. `decorate` runs on every frame Ink produces —
12
+ * 15-30 times a second during a run — while a copy happens when a human releases
13
+ * a mouse button, so stripping every row eagerly would spend H regex passes per
14
+ * frame to serve an event that may never come. The cache is dropped by `set`, so
15
+ * the laziness is invisible to callers and cannot go stale.
16
+ */
17
+ import stripAnsi from 'strip-ansi';
18
+ export function createScreenMirror() {
19
+ let raw = [];
20
+ let plain = null;
21
+ return {
22
+ get raw() {
23
+ return raw;
24
+ },
25
+ get plain() {
26
+ if (plain === null)
27
+ plain = raw.map((line) => stripAnsi(line));
28
+ return plain;
29
+ },
30
+ set(lines) {
31
+ raw = lines;
32
+ plain = null;
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The selection controller (tui-selection-and-scroll-follow §4.4). NON-REACT: it
3
+ * owns the mouse lifecycle, the 16 ms drag coalescer, the `decorate` hook and the
4
+ * copy-on-release, and it reaches the React tree only through the two callbacks
5
+ * `App` registers on mount.
6
+ *
7
+ * WHY IT IS NOT A COMPONENT (D-6). A React re-render per motion report goes
8
+ * through the render governor and the whole component tree; the frame pipeline
9
+ * repaints one to three rows with no commit at all. A fast drag across 40 columns
10
+ * therefore costs about three repaints of a few rows each, not forty full frames.
11
+ *
12
+ * THREE THINGS IN HERE FAIL SILENTLY AND PERMANENTLY IF DROPPED:
13
+ *
14
+ * - **I-11 · `hold` must always have a way out.** A press with no matching
15
+ * release freezes the viewport for the rest of the session: content anchoring
16
+ * never lets go, the resume timer's third condition never passes, and nothing
17
+ * is raised anywhere. Three ways to never get a release — the button comes up
18
+ * while the terminal does not have focus, the emulator swallows it, or the
19
+ * terminal fell back to X10, whose reports cannot express a release at all.
20
+ * So `hold` is released by a `release` report, by `clear()`, OR by the
21
+ * `holdMaxMs` watchdog, and the parser refuses to emit buttons from X10.
22
+ * - **I-9 · a highlight that survives the rows moving is a lie about what would
23
+ * be copied.** `App` clears on `shiftUp` change — the DERIVED form, which a
24
+ * new way to move the viewport cannot forget to join — and additionally on
25
+ * key, resize, overlay and `differ.invalidate()`.
26
+ * - **I-4 · the highlight and the copy read the SAME mirror**, written by the
27
+ * same `decorate` call. What you see is what you get by construction, rather
28
+ * than by a staleness check that can be wrong.
29
+ */
30
+ import type { MouseSource } from '../../input/stdin-mouse-filter.js';
31
+ import type { Theme } from '../theme.js';
32
+ import type { TermCapabilities } from '../capabilities.js';
33
+ import type { CopyVia } from '../clipboard.js';
34
+ /**
35
+ * How long a nominally-held button may sit MOTIONLESS before the watchdog lets
36
+ * go of it (I-11). Reset by every motion report, so it only fires when the
37
+ * pointer has not moved for the whole window — which is indistinguishable from a
38
+ * lost release, and the failure it prevents is unrecoverable while the one it
39
+ * risks costs a re-drag (R-12).
40
+ */
41
+ export declare const DEFAULT_HOLD_MAX_MS = 30000;
42
+ export interface SelectionControllerOptions {
43
+ source: MouseSource;
44
+ /**
45
+ * Ask the frame writer to re-paint. Returns whether anything reached the
46
+ * screen; `false` means the differ had no cache to diff against, and the
47
+ * caller falls back to `requestRedraw`.
48
+ */
49
+ repaint: () => boolean;
50
+ /** `App`'s `redrawNonce`, for the invalidated case (§4.4.3). */
51
+ requestRedraw: () => void;
52
+ copy: (text: string) => CopyVia;
53
+ /** Toast sink, registered by `App` on mount (§6.3). */
54
+ onCopied: (via: CopyVia, lines: number, chars: number) => void;
55
+ theme: () => Theme;
56
+ caps: TermCapabilities;
57
+ /** Terminal width, for the right edge of a multi-row highlight. */
58
+ cols: () => number;
59
+ /** Static half of the gate: full-screen and `mouseSelect`. */
60
+ isSelectable?: () => boolean;
61
+ /** I-11. Default 30_000; `0` disables the watchdog (tests). */
62
+ holdMaxMs?: number;
63
+ }
64
+ export interface SelectionController {
65
+ /** Feed to `createFrameDiffer({ decorate })`. Mirrors, then paints. */
66
+ decorate(lines: string[]): string[];
67
+ /** Subscribe to `hold` transitions; `App` mirrors this into React state. */
68
+ onHoldChange(listener: (hold: boolean) => void): () => void;
69
+ /** Called by `App` for key / resize / overlay / clear, and by `onInvalidate`. */
70
+ clear(): void;
71
+ /** The dynamic half of the gate: `App` closes it while an overlay is open. */
72
+ setEnabled(enabled: boolean): void;
73
+ dispose(): void;
74
+ }
75
+ /**
76
+ * The seam between the non-React controller and the React tree.
77
+ *
78
+ * SAME SHAPE AS `UpdateBridge` (`cli.tsx`), and for the same reason: the
79
+ * controller is constructed before `render()` and must not hold React state,
80
+ * while the toast sink and the redraw nonce only exist once `App` has mounted.
81
+ * `App` assigns both fields in an effect and clears them on unmount.
82
+ */
83
+ export interface SelectionBridge {
84
+ controller: SelectionController | null;
85
+ onCopied: ((via: CopyVia, lines: number, chars: number) => void) | null;
86
+ requestRedraw: (() => void) | null;
87
+ }
88
+ export declare function createSelectionController(options: SelectionControllerOptions): SelectionController;