@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,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-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;
@@ -0,0 +1,282 @@
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 { createScreenMirror } from './screen-mirror.js';
31
+ import { paintRow, selectionOpen } from './highlight.js';
32
+ import { isEmpty, normalize, rowSpan, selectedText, } from './selection.js';
33
+ /**
34
+ * Motion coalescing window. The same 16 ms and the same reasoning as
35
+ * `use-wheel-routing.ts`: this one is in the direct path of a hand gesture, so
36
+ * it is deliberately tighter than the 33 ms streaming coalescer.
37
+ */
38
+ const DRAG_COALESCE_MS = 16;
39
+ /**
40
+ * How long a nominally-held button may sit MOTIONLESS before the watchdog lets
41
+ * go of it (I-11). Reset by every motion report, so it only fires when the
42
+ * pointer has not moved for the whole window — which is indistinguishable from a
43
+ * lost release, and the failure it prevents is unrecoverable while the one it
44
+ * risks costs a re-drag (R-12).
45
+ */
46
+ export const DEFAULT_HOLD_MAX_MS = 30_000;
47
+ function clamp(value, min, max) {
48
+ if (!Number.isFinite(value))
49
+ return min;
50
+ return Math.min(max, Math.max(min, Math.floor(value)));
51
+ }
52
+ export function createSelectionController(options) {
53
+ const mirror = createScreenMirror();
54
+ const holdListeners = new Set();
55
+ const holdMaxMs = options.holdMaxMs ?? DEFAULT_HOLD_MAX_MS;
56
+ const isSelectable = options.isSelectable ?? (() => true);
57
+ let enabled = true;
58
+ let sel = null;
59
+ let dragging = false;
60
+ let pendingFocus = null;
61
+ let flushTimer = null;
62
+ let holdWatchdog = null;
63
+ let disposed = false;
64
+ /** Guards `decorate` against being re-entered from inside a repaint. */
65
+ let painting = false;
66
+ const emitHold = (hold) => {
67
+ for (const listener of [...holdListeners])
68
+ listener(hold);
69
+ };
70
+ const clearFlushTimer = () => {
71
+ if (!flushTimer)
72
+ return;
73
+ clearTimeout(flushTimer);
74
+ flushTimer = null;
75
+ };
76
+ const clearWatchdog = () => {
77
+ if (!holdWatchdog)
78
+ return;
79
+ clearTimeout(holdWatchdog);
80
+ holdWatchdog = null;
81
+ };
82
+ /**
83
+ * `0` disables it, which is what the tests that are not about it use. Every
84
+ * motion report re-arms it, so the window measures STILLNESS rather than the
85
+ * duration of the drag (R-12).
86
+ */
87
+ const armWatchdog = () => {
88
+ clearWatchdog();
89
+ if (holdMaxMs <= 0)
90
+ return;
91
+ holdWatchdog = setTimeout(() => {
92
+ holdWatchdog = null;
93
+ if (!dragging)
94
+ return;
95
+ // Let go of `hold` and the drag; KEEP whatever was selected. The user may
96
+ // still want to copy it, and throwing it away would turn a terminal quirk
97
+ // into lost work.
98
+ dragging = false;
99
+ pendingFocus = null;
100
+ clearFlushTimer();
101
+ emitHold(false);
102
+ }, holdMaxMs);
103
+ holdWatchdog.unref?.();
104
+ };
105
+ /**
106
+ * Repaint, falling back to a React redraw when the differ has no cache.
107
+ *
108
+ * `wanted` says whether something must APPEAR on screen. Clearing a highlight
109
+ * after an `invalidate()` needs no fallback — the raw frame is already on the
110
+ * screen without it — and asking for one there would turn every resize and
111
+ * every foreign write into a redraw nonce bump.
112
+ */
113
+ const paint = (wanted) => {
114
+ if (disposed)
115
+ return;
116
+ const painted = options.repaint();
117
+ if (!painted && wanted)
118
+ options.requestRedraw();
119
+ };
120
+ const clear = () => {
121
+ // Nothing to undo: no timers, no repaint, no redraw. This is what keeps
122
+ // `onInvalidate` — which fires on every resize and every foreign write —
123
+ // free for a session that has never selected anything.
124
+ if (sel === null && !dragging && pendingFocus === null)
125
+ return;
126
+ const wasDragging = dragging;
127
+ sel = null;
128
+ dragging = false;
129
+ pendingFocus = null;
130
+ clearFlushTimer();
131
+ clearWatchdog();
132
+ if (wasDragging)
133
+ emitHold(false);
134
+ paint(false);
135
+ };
136
+ const cellOf = (event) => {
137
+ // SGR reports are 1-based, frame line `i` is terminal row `i + 1`. A report
138
+ // from a terminal that resized mid-drag must not index past the mirror.
139
+ const rows = Math.max(1, mirror.raw.length);
140
+ const cols = Math.max(1, options.cols());
141
+ return { row: clamp(event.y - 1, 0, rows - 1), col: clamp(event.x - 1, 0, cols - 1) };
142
+ };
143
+ const flushDrag = () => {
144
+ clearFlushTimer();
145
+ const focus = pendingFocus;
146
+ pendingFocus = null;
147
+ if (!focus || !sel)
148
+ return;
149
+ sel = { anchor: sel.anchor, focus };
150
+ paint(true);
151
+ };
152
+ const finishSelection = () => {
153
+ if (!sel)
154
+ return;
155
+ const normal = normalize(sel);
156
+ if (isEmpty(normal)) {
157
+ // A plain click. It must never leave a one-cell highlight behind, and it
158
+ // must never clobber the clipboard.
159
+ sel = null;
160
+ paint(false);
161
+ return;
162
+ }
163
+ const text = selectedText(mirror.plain, normal);
164
+ if (text.length === 0) {
165
+ sel = null;
166
+ paint(false);
167
+ return;
168
+ }
169
+ const via = options.copy(text);
170
+ options.onCopied(via, text.split('\n').length, text.length);
171
+ // The highlight STAYS after a release: it is the receipt for what was taken,
172
+ // and it goes away on the next thing that moves the rows (I-9).
173
+ paint(true);
174
+ };
175
+ const onMouse = (event) => {
176
+ if (disposed)
177
+ return;
178
+ if (event.kind === 'wheel') {
179
+ // A wheel notch during a drag is a user changing their mind, not extending
180
+ // a selection — N1 already declines auto-scroll-while-dragging. The notch
181
+ // then scrolls normally, because the wheel router is a separate subscriber
182
+ // on the same channel.
183
+ if (dragging)
184
+ clear();
185
+ return;
186
+ }
187
+ if (event.kind === 'press') {
188
+ if (event.button !== 0 || !enabled || !isSelectable()) {
189
+ // Any other button, or a closed gate: drop whatever was selected and do
190
+ // nothing else. A right-click that left a stale highlight behind would be
191
+ // the same false promise I-9 forbids.
192
+ clear();
193
+ return;
194
+ }
195
+ const cell = cellOf(event);
196
+ sel = { anchor: cell, focus: cell };
197
+ pendingFocus = null;
198
+ clearFlushTimer();
199
+ dragging = true;
200
+ emitHold(true); // freezes the transcript for the duration of the drag
201
+ armWatchdog();
202
+ paint(true);
203
+ return;
204
+ }
205
+ if (event.kind === 'drag') {
206
+ if (!dragging || event.button !== 0 || !sel)
207
+ return;
208
+ pendingFocus = cellOf(event);
209
+ armWatchdog();
210
+ if (!flushTimer) {
211
+ flushTimer = setTimeout(flushDrag, DRAG_COALESCE_MS);
212
+ flushTimer.unref?.();
213
+ }
214
+ return;
215
+ }
216
+ // release
217
+ if (!dragging)
218
+ return;
219
+ if (pendingFocus && sel)
220
+ sel = { anchor: sel.anchor, focus: pendingFocus };
221
+ pendingFocus = null;
222
+ clearFlushTimer();
223
+ clearWatchdog();
224
+ dragging = false;
225
+ emitHold(false);
226
+ finishSelection();
227
+ };
228
+ const unsubscribe = options.source.subscribe(onMouse);
229
+ return {
230
+ decorate(lines) {
231
+ mirror.set(lines);
232
+ if (painting || sel === null)
233
+ return lines;
234
+ const normal = normalize(sel);
235
+ if (isEmpty(normal))
236
+ return lines;
237
+ painting = true;
238
+ try {
239
+ const open = selectionOpen(options.theme(), options.caps);
240
+ const cols = Math.max(1, options.cols());
241
+ const out = lines.slice();
242
+ for (let row = normal.start.row; row <= normal.end.row; row += 1) {
243
+ if (row < 0 || row >= out.length)
244
+ continue;
245
+ const span = rowSpan(normal, row);
246
+ if (!span)
247
+ continue;
248
+ const to = Math.min(Number.isFinite(span.to) ? span.to : cols, cols);
249
+ out[row] = paintRow(out[row], span.from, to, open);
250
+ }
251
+ return out;
252
+ }
253
+ finally {
254
+ painting = false;
255
+ }
256
+ },
257
+ onHoldChange(listener) {
258
+ holdListeners.add(listener);
259
+ return () => holdListeners.delete(listener);
260
+ },
261
+ clear,
262
+ setEnabled(next) {
263
+ if (enabled === next)
264
+ return;
265
+ enabled = next;
266
+ if (!next)
267
+ clear();
268
+ },
269
+ dispose() {
270
+ if (disposed)
271
+ return;
272
+ disposed = true;
273
+ unsubscribe();
274
+ clearFlushTimer();
275
+ clearWatchdog();
276
+ holdListeners.clear();
277
+ sel = null;
278
+ dragging = false;
279
+ pendingFocus = null;
280
+ },
281
+ };
282
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The selection model (tui-selection-and-scroll-follow §4.4.4). PURE — no React,
3
+ * no I/O, no terminal.
4
+ *
5
+ * Coordinates are SCREEN CELLS, 0-based: frame line `i` is terminal row `i + 1`
6
+ * and column `x - 1`, so an SGR report at `(x, y)` becomes `{ row: y - 1, col: x
7
+ * - 1 }`. Screen coordinates are only sound while the rows hold still, and
8
+ * holding them still is Stage 2's job — `press` sets `hold`, which anchors the
9
+ * content and suspends the resume timer, so the transcript is frozen for the
10
+ * duration of a drag (D-7).
11
+ *
12
+ * ═══════════════════════════════════════════════════════════════════════════
13
+ * I-12 — `sliceColumns` IS THE ONE COLUMN→CHARACTER MAPPING IN THIS FEATURE,
14
+ * and `stringWidth` is its only width oracle.
15
+ *
16
+ * The painter and the extractor are handed the SAME half-open column range by
17
+ * `rowSpan`. If they turn that range into characters by different rules, the
18
+ * copied text is not the highlighted text — silently, which is the one failure
19
+ * mode I-4 exists to name. That is not hypothetical: `slice-ansi` advances its
20
+ * cursor by `isFullWidth ? 2 : character.length`, i.e. display columns for CJK
21
+ * but UTF-16 CODE UNITS for everything else, so a combining mark costs it a
22
+ * column that occupies none and an astral code point costs two; and the obvious
23
+ * `String.slice` for the extractor is code units throughout. Two mappings from
24
+ * one range agree on ASCII and diverge on exactly the rows a user is most likely
25
+ * to want to copy.
26
+ *
27
+ * So both call the function below, which walks GRAPHEME CLUSTERS and charges
28
+ * each one `stringWidth(cluster)` columns. What is hand-rolled here is only the
29
+ * column arithmetic — the width table is still `string-width`'s, and the escape
30
+ * bookkeeping is `slice-ansi`'s idea re-indexed (R-13).
31
+ * ═══════════════════════════════════════════════════════════════════════════
32
+ */
33
+ export interface Cell {
34
+ /** 0-based frame line. */
35
+ row: number;
36
+ /** 0-based screen column. */
37
+ col: number;
38
+ }
39
+ export interface Selection {
40
+ anchor: Cell;
41
+ focus: Cell;
42
+ }
43
+ /** `start <= end` in reading order. */
44
+ export interface NormalSelection {
45
+ start: Cell;
46
+ end: Cell;
47
+ }
48
+ /** A half-open column range on one row; `to` may be `Infinity` (to end of line). */
49
+ export interface RowSpan {
50
+ from: number;
51
+ to: number;
52
+ }
53
+ export declare function normalize(sel: Selection): NormalSelection;
54
+ export declare function isEmpty(sel: NormalSelection): boolean;
55
+ /**
56
+ * The half-open column range to highlight on `row`, or `null` when the row is
57
+ * outside the selection.
58
+ *
59
+ * LINEAR, NOT RECTANGULAR — what every terminal does: the first row runs from
60
+ * `start.col` to end of line, whole middle rows are taken entirely, and the last
61
+ * row stops at `end.col`. `Infinity` means "to the end of the line"; the painter
62
+ * clamps it to the terminal width so a multi-row selection gets a straight right
63
+ * edge, and the extractor treats it as "no upper bound".
64
+ */
65
+ export declare function rowSpan(sel: NormalSelection, row: number): RowSpan | null;
66
+ /**
67
+ * The text a release would put on the clipboard.
68
+ *
69
+ * `plainRows` are the mirror's ANSI-STRIPPED rows — the same rows the highlight
70
+ * was painted over, which is what makes I-4 hold by construction rather than by
71
+ * a staleness check that can be wrong.
72
+ */
73
+ export declare function selectedText(plainRows: readonly string[], sel: NormalSelection): string;
74
+ /**
75
+ * Slice `text` by SCREEN COLUMNS, half-open `[from, to)`.
76
+ *
77
+ * Three properties are load-bearing:
78
+ *
79
+ * - **Escape codes opened before `from` are carried into the result.** Without
80
+ * it the painter's tail — `sliceColumns(line, to)` — would lose the colour
81
+ * `Markdown` and `cli-highlight` gave it, on every row a selection touches.
82
+ * - **A wide cluster straddling either boundary becomes spaces for the part
83
+ * that is inside.** That keeps the arithmetic EXACT in columns, which is the
84
+ * whole point of I-12: half a CJK glyph is not a character, and inventing one
85
+ * would put the painter and the extractor back out of step.
86
+ * - **`to` is optional and means "to the end of the line".**
87
+ */
88
+ export declare function sliceColumns(text: string, from: number, to?: number): string;
89
+ /** Display width in columns, using the same oracle `sliceColumns` charges with. */
90
+ export declare function columnsOf(text: string): number;