@aragon-agent/cli 0.6.8 → 0.6.10

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 (97) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +89 -44
  3. package/dist/agent/controller.d.ts +1 -1
  4. package/dist/agent/reducer.d.ts +31 -113
  5. package/dist/agent/reducer.js +41 -63
  6. package/dist/cli.js +53 -155
  7. package/dist/commands/builtins.d.ts +2 -1
  8. package/dist/commands/builtins.js +47 -2
  9. package/dist/commands/perf.d.ts +32 -2
  10. package/dist/commands/perf.js +32 -2
  11. package/dist/commands/registry.d.ts +2 -2
  12. package/dist/config/env.js +0 -130
  13. package/dist/config/load.d.ts +0 -2
  14. package/dist/config/load.js +2 -31
  15. package/dist/config/schema.d.ts +1 -14
  16. package/dist/config/schema.js +0 -1
  17. package/dist/config/store.js +2 -0
  18. package/dist/input/enter-sequences.d.ts +63 -0
  19. package/dist/input/enter-sequences.js +151 -0
  20. package/dist/input/limits.d.ts +11 -0
  21. package/dist/input/limits.js +11 -0
  22. package/dist/input/stdin-filter.js +19 -1
  23. package/dist/logging/install.d.ts +2 -2
  24. package/dist/logging/install.js +2 -2
  25. package/dist/session/persist.js +20 -0
  26. package/dist/todo/follow-through.d.ts +0 -4
  27. package/dist/todo/follow-through.js +0 -4
  28. package/dist/todo/limits.d.ts +0 -10
  29. package/dist/todo/limits.js +0 -10
  30. package/dist/todo/prompt.d.ts +0 -15
  31. package/dist/ui/ActivityLine.d.ts +26 -34
  32. package/dist/ui/ActivityLine.js +30 -19
  33. package/dist/ui/App.d.ts +1 -41
  34. package/dist/ui/App.js +94 -129
  35. package/dist/ui/BottomStatusRow.d.ts +1 -3
  36. package/dist/ui/BottomStatusRow.js +4 -8
  37. package/dist/ui/Composer.d.ts +28 -1
  38. package/dist/ui/Composer.js +46 -17
  39. package/dist/ui/Header.d.ts +2 -16
  40. package/dist/ui/Header.js +3 -18
  41. package/dist/ui/Logo.d.ts +1 -17
  42. package/dist/ui/Logo.js +0 -16
  43. package/dist/ui/ModeChip.d.ts +7 -18
  44. package/dist/ui/ModeChip.js +9 -0
  45. package/dist/ui/PromptInput.d.ts +2 -28
  46. package/dist/ui/PromptInput.js +38 -9
  47. package/dist/ui/StatusBar.d.ts +0 -11
  48. package/dist/ui/ToastStack.d.ts +2 -18
  49. package/dist/ui/ToastStack.js +4 -11
  50. package/dist/ui/Transcript.d.ts +5 -71
  51. package/dist/ui/Transcript.js +15 -199
  52. package/dist/ui/enter-frames.d.ts +63 -0
  53. package/dist/ui/enter-frames.js +136 -0
  54. package/dist/ui/entries/AssistantEntry.d.ts +0 -23
  55. package/dist/ui/entries/AssistantEntry.js +3 -4
  56. package/dist/ui/entries/DiffView.d.ts +1 -7
  57. package/dist/ui/entries/DiffView.js +2 -2
  58. package/dist/ui/entries/QueuedEntry.d.ts +27 -0
  59. package/dist/ui/entries/QueuedEntry.js +30 -0
  60. package/dist/ui/entries/ToolCard.d.ts +0 -6
  61. package/dist/ui/entries/ToolCard.js +4 -13
  62. package/dist/ui/frame-differ.d.ts +13 -0
  63. package/dist/ui/frame-differ.js +36 -6
  64. package/dist/ui/glyphs.d.ts +9 -0
  65. package/dist/ui/glyphs.js +2 -0
  66. package/dist/ui/ink-runtime.d.ts +1 -0
  67. package/dist/ui/ink-runtime.js +21 -0
  68. package/dist/ui/layout/AppShell.d.ts +1 -10
  69. package/dist/ui/layout/AppShell.js +2 -5
  70. package/dist/ui/layout/OverlayFrame.d.ts +1 -25
  71. package/dist/ui/layout/OverlayFrame.js +0 -24
  72. package/dist/ui/layout/ScrollViewport.d.ts +3 -0
  73. package/dist/ui/layout/ScrollViewport.js +17 -5
  74. package/dist/ui/layout/frame.d.ts +0 -22
  75. package/dist/ui/layout/frame.js +0 -36
  76. package/dist/ui/layout/todo-layout.d.ts +1 -3
  77. package/dist/ui/layout/todo-layout.js +7 -8
  78. package/dist/ui/layout/viewport-geometry.d.ts +1 -1
  79. package/dist/ui/layout/viewport-geometry.js +1 -1
  80. package/dist/ui/live-clamp.d.ts +1 -29
  81. package/dist/ui/live-clamp.js +1 -29
  82. package/dist/ui/overlays/HelpOverlay.js +3 -1
  83. package/dist/ui/overlays/PlanReviewOverlay.js +5 -1
  84. package/dist/ui/overlays/QuestionOverlay.js +7 -2
  85. package/dist/ui/overlays/SettingsScreen.js +8 -1
  86. package/dist/ui/run-status-row.d.ts +61 -0
  87. package/dist/ui/run-status-row.js +70 -0
  88. package/dist/ui/selection/selection-controller.d.ts +23 -7
  89. package/dist/ui/selection/selection-controller.js +68 -16
  90. package/dist/ui/transcript-text.d.ts +0 -8
  91. package/dist/ui/transcript-text.js +0 -8
  92. package/dist/ui/use-startup-notices.d.ts +6 -58
  93. package/dist/ui/use-startup-notices.js +10 -57
  94. package/dist/ui/use-wheel-routing.d.ts +1 -1
  95. package/package.json +2 -2
  96. package/dist/ui/TodoStrip.d.ts +0 -36
  97. package/dist/ui/TodoStrip.js +0 -32
@@ -20,6 +20,7 @@ import { Box, Text, useInput } from 'ink';
20
20
  import { pickGlyphs } from '../glyphs.js';
21
21
  import { OverlayFrame } from '../layout/OverlayFrame.js';
22
22
  import { stripPasteFrames } from '../paste-frames.js';
23
+ import { stripEnterFrames } from '../enter-frames.js';
23
24
  /** Which option index each question starts on: the recommended one. */
24
25
  function initialCursors(questions) {
25
26
  return questions.map((q) => Math.max(0, q.options.findIndex((o) => o.recommended)));
@@ -125,8 +126,12 @@ export function QuestionOverlay({ questions, maxRows, cols, theme, caps, onSubmi
125
126
  // `stripPasteFrames`, NOT `input` (I-12 / P0-2) -- the same broadcast
126
127
  // hazard `SettingsScreen` documents at length: a framed paste appended
127
128
  // verbatim stores NULs that render as nothing.
128
- if (input && !key.ctrl && !key.meta && !key.tab)
129
- setDraft(draft + stripPasteFrames(input));
129
+ // `stripEnterFrames` rides inside it for the newline frame a
130
+ // Shift+Enter broadcasts (tui-shift-enter-copy-queue 3.5): same hazard,
131
+ // same fix, and this order is the one that leaves no NUL in the draft.
132
+ if (input && !key.ctrl && !key.meta && !key.tab) {
133
+ setDraft(draft + stripPasteFrames(stripEnterFrames(input)));
134
+ }
130
135
  return;
131
136
  }
132
137
  // phase === 'choosing'
@@ -16,6 +16,7 @@ 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
  import { stripPasteFrames } from '../paste-frames.js';
19
+ import { stripEnterFrames } from '../enter-frames.js';
19
20
  const SHOW_THINKING_OPTIONS = ['off', 'on'];
20
21
  const LIVE_OUTPUT_OPTIONS = ['off', 'on'];
21
22
  const FAST_ENABLED_OPTIONS = ['off', 'on'];
@@ -302,7 +303,13 @@ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset,
302
303
  // `controller.setApiKey()`. The user is told the settings were saved and
303
304
  // every request afterwards fails to authenticate with nothing on screen to
304
305
  // explain it. Pasting IS how a key enters this process.
305
- const text = stripPasteFrames(input);
306
+ // `stripEnterFrames` INSIDE (tui-shift-enter-copy-queue 3.5): the same
307
+ // broadcast carries the newline frame a Shift+Enter produced, and this
308
+ // composition order is the one that leaves no NUL in the field -- the
309
+ // outer paste strip removes the frame's newline for a single-line field
310
+ // exactly as it removes a bare Ctrl+J today, while the reverse order
311
+ // would leak the frame's letter as visible text.
312
+ const text = stripPasteFrames(stripEnterFrames(input));
306
313
  if (text.length === 0)
307
314
  return;
308
315
  setValues((prev) => ({ ...prev, [field.key]: String(prev[field.key]) + text }));
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The run status row's width plan (tui-scrollbar-edge-and-run-row §3.3.3).
3
+ *
4
+ * While a run is in flight, ONE row above the input box carries the spinner, the
5
+ * phrase and the `steer / interrupt / exit` hint clauses, and `planRunRow` decides
6
+ * what survives when the line is too narrow. It is a pure function with no React
7
+ * and no I/O so the whole ladder can be tested as arithmetic.
8
+ *
9
+ * THE ALLOCATION IS PER CLAUSE, NOT PER STRING, and that is the whole point.
10
+ * The idle hint row this one replaces owned the full line width and ended in
11
+ * `wrap="truncate"`; handing the label half the line and truncating the joined
12
+ * hint would cut `interrupt` to `inter...` at 38 columns - a regression of the
13
+ * "running hint is NEVER abbreviated" rule `Composer.hintText` documents. So:
14
+ *
15
+ * - clauses are dropped WHOLE, from the tail (`exit`, then `stop N`), and
16
+ * - `steer` and `interrupt` (REQUIRED_CLAUSES) are never dropped or cut mid-word
17
+ * while the row is enabled, and
18
+ * - the only thing allowed to end in an ellipsis is the label.
19
+ */
20
+ /** Everything the label needs, kept in one shape so there is one place that names it. */
21
+ export interface RunActivity {
22
+ /** Epoch ms the current run began - the phrase sequence's seed. */
23
+ startedAt: number;
24
+ /** `Date.now() - startedAt`, already computed by App's 200 ms ticker. */
25
+ elapsedMs: number;
26
+ /** The RAW config flag, never the widened one (single-spinner D-5). */
27
+ reducedMotion: boolean;
28
+ /** The tool currently in flight, if any. */
29
+ runningTool?: string;
30
+ /** A context compaction is in flight; outranks the tool name and the phrase. */
31
+ compacting?: boolean;
32
+ }
33
+ export interface RunRowPlanInput {
34
+ /** Width of the row's container (the composer's content columns). */
35
+ cols: number;
36
+ /** Spinner + space + label, in display columns (the label's natural width). */
37
+ labelCols: number;
38
+ /** Display width of each hint clause, most urgent first. */
39
+ hintClauseCols: readonly number[];
40
+ /** Display width of the separator between clauses (` · ` or ` - `). */
41
+ separatorCols: number;
42
+ /** Display width of the right-aligned mode chip; 0 = no chip. */
43
+ chipCols: number;
44
+ }
45
+ export interface RunRowPlan {
46
+ /** Columns the label may occupy; below its natural width it is ellipsized. */
47
+ labelCols: number;
48
+ /** How many leading clauses to show; 0 = none (defensive tier only). */
49
+ hintClauses: number;
50
+ /** Whether the chip fits after everything else. */
51
+ chip: boolean;
52
+ }
53
+ /** One leading space, matching the idle hint row's `' '` indent. */
54
+ export declare const RUN_ROW_LEAD = 1;
55
+ /** Narrowest the label may be squeezed before a clause is dropped instead. */
56
+ export declare const RUN_ROW_MIN_LABEL_COLS = 10;
57
+ /** `steer` and `interrupt`: always whole while the row is enabled. */
58
+ export declare const RUN_ROW_REQUIRED_CLAUSES = 2;
59
+ /** Space kept between the last clause and the chip. */
60
+ export declare const RUN_ROW_CHIP_GAP = 2;
61
+ export declare function planRunRow(input: RunRowPlanInput): RunRowPlan;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The run status row's width plan (tui-scrollbar-edge-and-run-row §3.3.3).
3
+ *
4
+ * While a run is in flight, ONE row above the input box carries the spinner, the
5
+ * phrase and the `steer / interrupt / exit` hint clauses, and `planRunRow` decides
6
+ * what survives when the line is too narrow. It is a pure function with no React
7
+ * and no I/O so the whole ladder can be tested as arithmetic.
8
+ *
9
+ * THE ALLOCATION IS PER CLAUSE, NOT PER STRING, and that is the whole point.
10
+ * The idle hint row this one replaces owned the full line width and ended in
11
+ * `wrap="truncate"`; handing the label half the line and truncating the joined
12
+ * hint would cut `interrupt` to `inter...` at 38 columns - a regression of the
13
+ * "running hint is NEVER abbreviated" rule `Composer.hintText` documents. So:
14
+ *
15
+ * - clauses are dropped WHOLE, from the tail (`exit`, then `stop N`), and
16
+ * - `steer` and `interrupt` (REQUIRED_CLAUSES) are never dropped or cut mid-word
17
+ * while the row is enabled, and
18
+ * - the only thing allowed to end in an ellipsis is the label.
19
+ */
20
+ /** One leading space, matching the idle hint row's `' '` indent. */
21
+ export const RUN_ROW_LEAD = 1;
22
+ /** Narrowest the label may be squeezed before a clause is dropped instead. */
23
+ export const RUN_ROW_MIN_LABEL_COLS = 10;
24
+ /** `steer` and `interrupt`: always whole while the row is enabled. */
25
+ export const RUN_ROW_REQUIRED_CLAUSES = 2;
26
+ /** Space kept between the last clause and the chip. */
27
+ export const RUN_ROW_CHIP_GAP = 2;
28
+ /** Non-finite or negative widths collapse to 0 so no input can make the plan throw. */
29
+ function width(value) {
30
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
31
+ }
32
+ /** Width of the first `count` clauses joined by `separator`. */
33
+ function clausesWidth(clauses, count, separator) {
34
+ if (count <= 0)
35
+ return 0;
36
+ let total = separator * (count - 1);
37
+ for (let i = 0; i < count; i += 1)
38
+ total += clauses[i] ?? 0;
39
+ return total;
40
+ }
41
+ export function planRunRow(input) {
42
+ const inner = Math.max(0, width(input.cols) - RUN_ROW_LEAD);
43
+ const natural = width(input.labelCols);
44
+ const separator = width(input.separatorCols);
45
+ const chipCols = width(input.chipCols);
46
+ const clauses = input.hintClauseCols.map(width);
47
+ const total = clauses.length;
48
+ const labelOnly = {
49
+ labelCols: Math.min(natural, inner), hintClauses: 0, chip: false,
50
+ };
51
+ if (total < RUN_ROW_REQUIRED_CLAUSES)
52
+ return labelOnly;
53
+ // Tier 1: everything fits; the chip is the first thing to go.
54
+ const everything = natural + separator + clausesWidth(clauses, total, separator);
55
+ if (everything <= inner) {
56
+ const chip = chipCols > 0 && everything + RUN_ROW_CHIP_GAP + chipCols <= inner;
57
+ return { labelCols: natural, hintClauses: total, chip };
58
+ }
59
+ // Tier 2: drop clauses from the tail, but never below the required two; only the
60
+ // label may be squeezed (down to its minimum) to make the remaining clauses fit.
61
+ for (let kept = total - 1; kept >= RUN_ROW_REQUIRED_CLAUSES; kept -= 1) {
62
+ const available = inner - separator - clausesWidth(clauses, kept, separator);
63
+ if (available >= RUN_ROW_MIN_LABEL_COLS) {
64
+ return { labelCols: Math.min(natural, available), hintClauses: kept, chip: false };
65
+ }
66
+ }
67
+ // Tier 3 (defensive, below ~40 columns): the label alone. The status bar's own
68
+ // `Esc x2 interrupt` carries the urgent hint at this width.
69
+ return labelOnly;
70
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
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.
3
+ * owns the mouse lifecycle, the 16 ms drag coalescer, the `decorate` hook and
4
+ * the released-selection state Ctrl+C consumes, and it reaches the React tree
5
+ * only through the callbacks `App` registers on mount.
6
6
  *
7
7
  * WHY IT IS NOT A COMPONENT (D-6). A React re-render per motion report goes
8
8
  * through the render governor and the whole component tree; the frame pipeline
@@ -28,9 +28,9 @@
28
28
  * than by a staleness check that can be wrong.
29
29
  */
30
30
  import type { MouseSource } from '../../input/stdin-filter.js';
31
+ import type { CopyVia } from '../clipboard.js';
31
32
  import type { Theme } from '../theme.js';
32
33
  import type { TermCapabilities } from '../capabilities.js';
33
- import type { CopyVia } from '../clipboard.js';
34
34
  /**
35
35
  * How long a nominally-held button may sit MOTIONLESS before the watchdog lets
36
36
  * go of it (I-11). Reset by every motion report, so it only fires when the
@@ -49,9 +49,6 @@ export interface SelectionControllerOptions {
49
49
  repaint: () => boolean;
50
50
  /** `App`'s `redrawNonce`, for the invalidated case (§4.4.3). */
51
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
52
  theme: () => Theme;
56
53
  caps: TermCapabilities;
57
54
  /** Terminal width, for the right edge of a multi-row highlight. */
@@ -64,6 +61,25 @@ export interface SelectionControllerOptions {
64
61
  export interface SelectionController {
65
62
  /** Feed to `createFrameDiffer({ decorate })`. Mirrors, then paints. */
66
63
  decorate(lines: string[]): string[];
64
+ /**
65
+ * Whether a RELEASED, non-empty selection is waiting for Ctrl+C
66
+ * (tui-shift-enter-copy-queue 4.2). Dragging does not count: the gesture
67
+ * is not finished, and a copy key pressed mid-drag must fall through to
68
+ * whatever it meant before this feature.
69
+ */
70
+ hasPendingSelection(): boolean;
71
+ /**
72
+ * Take the pending selection: returns its text and line count, and clears
73
+ * the highlight, the settled state and the hold. `null` when nothing is
74
+ * pending. The caller owns the copy itself (`copyText` is the single
75
+ * clipboard path) and MUST take BEFORE copying: OSC 52 writes through
76
+ * `writeForeign`, the differ invalidates, and its `onInvalidate` clears
77
+ * this controller -- taking first makes that clear a no-op (4.3 / R7).
78
+ */
79
+ takeSelection(): {
80
+ text: string;
81
+ lines: number;
82
+ } | null;
67
83
  /** Subscribe to `hold` transitions; `App` mirrors this into React state. */
68
84
  onHoldChange(listener: (hold: boolean) => void): () => void;
69
85
  /** Called by `App` for key / resize / overlay / clear, and by `onInvalidate`. */
@@ -1,8 +1,8 @@
1
1
  /**
2
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.
3
+ * owns the mouse lifecycle, the 16 ms drag coalescer, the `decorate` hook and
4
+ * the released-selection state Ctrl+C consumes, and it reaches the React tree
5
+ * only through the callbacks `App` registers on mount.
6
6
  *
7
7
  * WHY IT IS NOT A COMPONENT (D-6). A React re-render per motion report goes
8
8
  * through the render governor and the whole component tree; the frame pipeline
@@ -57,6 +57,10 @@ export function createSelectionController(options) {
57
57
  let enabled = true;
58
58
  let sel = null;
59
59
  let dragging = false;
60
+ /** A released, non-empty selection is waiting for Ctrl+C (4.2.1). */
61
+ let settled = false;
62
+ /** Mirror of the last `hold` emitted, so exits can be conditional. */
63
+ let holdActive = false;
60
64
  let pendingFocus = null;
61
65
  let flushTimer = null;
62
66
  let holdWatchdog = null;
@@ -64,6 +68,7 @@ export function createSelectionController(options) {
64
68
  /** Guards `decorate` against being re-entered from inside a repaint. */
65
69
  let painting = false;
66
70
  const emitHold = (hold) => {
71
+ holdActive = hold;
67
72
  for (const listener of [...holdListeners])
68
73
  listener(hold);
69
74
  };
@@ -121,15 +126,18 @@ export function createSelectionController(options) {
121
126
  // Nothing to undo: no timers, no repaint, no redraw. This is what keeps
122
127
  // `onInvalidate` — which fires on every resize and every foreign write —
123
128
  // free for a session that has never selected anything.
124
- if (sel === null && !dragging && pendingFocus === null)
129
+ if (sel === null && !dragging && pendingFocus === null && !settled)
125
130
  return;
126
- const wasDragging = dragging;
127
131
  sel = null;
128
132
  dragging = false;
133
+ settled = false;
129
134
  pendingFocus = null;
130
135
  clearFlushTimer();
131
136
  clearWatchdog();
132
- if (wasDragging)
137
+ // `holdActive`, not `wasDragging`: since tui-shift-enter-copy-queue a
138
+ // RELEASED selection also holds the viewport (4.2.3), so this is the
139
+ // exit for two states, not one.
140
+ if (holdActive)
133
141
  emitHold(false);
134
142
  paint(false);
135
143
  };
@@ -150,26 +158,38 @@ export function createSelectionController(options) {
150
158
  paint(true);
151
159
  };
152
160
  const finishSelection = () => {
153
- if (!sel)
161
+ if (!sel) {
162
+ emitHold(false);
154
163
  return;
164
+ }
155
165
  const normal = normalize(sel);
156
166
  if (isEmpty(normal)) {
157
- // A plain click. It must never leave a one-cell highlight behind, and it
158
- // must never clobber the clipboard.
167
+ // A plain click. It must never leave a one-cell highlight behind, and
168
+ // it must never clobber the clipboard.
159
169
  sel = null;
170
+ settled = false;
171
+ emitHold(false);
160
172
  paint(false);
161
173
  return;
162
174
  }
163
- const text = selectedText(mirror.plain, normal);
164
- if (text.length === 0) {
175
+ if (selectedText(mirror.plain, normal).length === 0) {
176
+ // Geometry without content (blank cells): nothing honest to promise a
177
+ // later Ctrl+C, so this is the same no-op a click is.
165
178
  sel = null;
179
+ settled = false;
180
+ emitHold(false);
166
181
  paint(false);
167
182
  return;
168
183
  }
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).
184
+ // RELEASED AND NON-EMPTY: the highlight STAYS and so does the hold
185
+ // (4.2.1 / 4.2.3). The highlight is the promise of what Ctrl+C will
186
+ // copy -- not a receipt for a copy that already happened -- and the
187
+ // hold keeps the rows under it frozen until that promise resolves,
188
+ // which is what lets "what you see is what you get" survive a
189
+ // streaming run. The watchdog is NOT re-armed: the release already
190
+ // happened, and the frozen state has explicit exits (Ctrl+C, any other
191
+ // key, resize, overlay, /mouse off).
192
+ settled = true;
173
193
  paint(true);
174
194
  };
175
195
  const onMouse = (event) => {
@@ -222,7 +242,9 @@ export function createSelectionController(options) {
222
242
  clearFlushTimer();
223
243
  clearWatchdog();
224
244
  dragging = false;
225
- emitHold(false);
245
+ // `finishSelection` decides the hold: a non-empty release KEEPS it
246
+ // (4.2.3) so the pending selection cannot be pushed off screen; every
247
+ // empty path releases it there.
226
248
  finishSelection();
227
249
  };
228
250
  const unsubscribe = options.source.subscribe(onMouse);
@@ -258,6 +280,32 @@ export function createSelectionController(options) {
258
280
  holdListeners.add(listener);
259
281
  return () => holdListeners.delete(listener);
260
282
  },
283
+ hasPendingSelection() {
284
+ return settled && sel !== null;
285
+ },
286
+ takeSelection() {
287
+ if (!settled || sel === null)
288
+ return null;
289
+ const normal = normalize(sel);
290
+ const text = selectedText(mirror.plain, normal);
291
+ // CONSUMED BEFORE THE CALLER COPIES (4.3 / R7). The copy writes OSC
292
+ // 52 through `writeForeign`; the differ treats that as a foreign
293
+ // write and invalidates, and `onInvalidate` clears this controller on
294
+ // the next macrotask. Clearing first makes that callback a no-op;
295
+ // copying first would let it erase the held viewport state a tick
296
+ // after the copy -- the "copy flashes the screen" regression.
297
+ sel = null;
298
+ settled = false;
299
+ pendingFocus = null;
300
+ clearFlushTimer();
301
+ clearWatchdog();
302
+ if (holdActive)
303
+ emitHold(false);
304
+ paint(false);
305
+ if (text.length === 0)
306
+ return null;
307
+ return { text, lines: text.split('\n').length };
308
+ },
261
309
  clear,
262
310
  setEnabled(next) {
263
311
  if (enabled === next)
@@ -270,6 +318,10 @@ export function createSelectionController(options) {
270
318
  if (disposed)
271
319
  return;
272
320
  disposed = true;
321
+ // 4.2.3: a pending selection holds the viewport; teardown must let go
322
+ // of it explicitly, exactly like `clear()` does.
323
+ if (holdActive)
324
+ emitHold(false);
273
325
  unsubscribe();
274
326
  clearFlushTimer();
275
327
  clearWatchdog();
@@ -1,11 +1,3 @@
1
- /**
2
- * Plain-text session rendering (spec §4.4). Pure, colorless, ANSI-free, so the
3
- * exit replay can be piped, diffed, or pasted into an issue.
4
- *
5
- * Used only by the full-screen exit replay: inline mode already left the real
6
- * transcript in the terminal's native scrollback, and replaying there would
7
- * simply print everything twice.
8
- */
9
1
  import type { Entry, UsageTotal } from '../agent/reducer.js';
10
2
  import type { Glyphs } from './glyphs.js';
11
3
  export interface TranscriptTextOptions {
@@ -1,11 +1,3 @@
1
- /**
2
- * Plain-text session rendering (spec §4.4). Pure, colorless, ANSI-free, so the
3
- * exit replay can be piped, diffed, or pasted into an issue.
4
- *
5
- * Used only by the full-screen exit replay: inline mode already left the real
6
- * transcript in the terminal's native scrollback, and replaying there would
7
- * simply print everything twice.
8
- */
9
1
  import { formatCost, formatDuration, formatTokens, promptTokensOf } from '../agent/usage.js';
10
2
  import { formatRetryLine } from '../agent/retry-view.js';
11
3
  const DEFAULT_MAX_ENTRIES = 200;
@@ -22,8 +22,12 @@ import type { ViewAction } from '../agent/reducer.js';
22
22
  *
23
23
  * `1` = the first text that names DRAG-SELECT and `/mouse`. `0` = the 0.6.2 text
24
24
  * that taught the Shift bypass, recorded back when this was a boolean.
25
+ * `2` = the Ctrl+C-to-copy text (tui-shift-enter-copy-queue 4.4): releasing
26
+ * no longer copies, and every user who learned the old gesture -- which is
27
+ * every user who has a version recorded at all -- has to be told the new
28
+ * one, once.
25
29
  */
26
- export declare const MOUSE_NOTICE_VERSION = 1;
30
+ export declare const MOUSE_NOTICE_VERSION = 2;
27
31
  /**
28
32
  * The revision of the text below that a user must have seen for this notice to
29
33
  * stay quiet (`state.json::vtInputNoticeVersion`, `0` when absent).
@@ -40,53 +44,7 @@ export declare const MOUSE_NOTICE_VERSION = 1;
40
44
  * that offered only `/plan`, recorded back when this was a boolean.
41
45
  */
42
46
  export declare const VT_INPUT_NOTICE_VERSION = 1;
43
- /**
44
- * The `shift-tab-and-mouse-wheel-dead-on-some-terminals` notice (F1'). Shown at
45
- * most once per `VT_INPUT_NOTICE_VERSION`, and only on a Windows console that
46
- * cannot deliver either input — see `ui/win-vt-input.ts` for why that is a
47
- * property of the Node version rather than of the terminal.
48
- *
49
- * IT NAMES WHAT THE KEY DOES NOW, not merely that it is unavailable, and that
50
- * is the load-bearing half of the text. `Shift+Tab` on these machines is not
51
- * inert: the modifier is lost inside libuv, so Ink sees an ordinary `Tab` and
52
- * the `/` palette or an `@` popup completes its selection over the user's
53
- * draft. A user told only "this key does not work" keeps pressing it and keeps
54
- * losing text without ever connecting the two.
55
- *
56
- * THE FALLBACK KEY COMES BEFORE `/plan` IN BOTH BRANCHES, and it is named from
57
- * `MODE_TOGGLE_KEYS` rather than spelled out, so the four consumers of that
58
- * constant cannot drift apart. This notice is the last one of them to be wired
59
- * up, and it is also the only channel that reaches the affected user without
60
- * being asked to: the composer hint does not exist at all in inline mode and is
61
- * switched off by a short terminal or `hints: false` in full-screen, and
62
- * `/help` has to be typed by someone whose current belief is "the key does
63
- * nothing" rather than "I should read the help". Ordering follows cost — one
64
- * keystroke before four and a return.
65
- *
66
- * `Shift+Up` / `Shift+Down` is the scroll workaround named here because it is
67
- * the only one PROVEN to survive: both Node versions emit `\x1b[1;2A` for it,
68
- * byte for byte, in the probe data. `PgUp` / `PgDn` is expected to work too but
69
- * was never injected, so it is left out rather than promised.
70
- *
71
- * THE WHEEL HALF IS FULL-SCREEN ONLY, and dropping it inline is not a detail.
72
- * `Shift+Tab` is broken in both modes — the modifier dies inside libuv, well
73
- * below anything this app chooses — but the wheel is only ours to lose in
74
- * full-screen: inline never asks the console to report it (`mode` is a conjunct
75
- * of `cli.tsx::wantMouse`), so the wheel goes on scrolling the terminal's own
76
- * scrollback exactly as it always has. And `Shift+Up` / `Shift+Down` are handled
77
- * inside `App`'s `if (fullscreen)` branch, so recommending them inline names a
78
- * key that does nothing. Telling an inline user their working wheel is dead and
79
- * then handing them an inert workaround is the same failure this notice exists
80
- * to end — advice about a mode that is not running — merely pointed the other
81
- * way.
82
- *
83
- * EXPORTED FOR ITS TEST, unlike the two notices above it. Asserting this text
84
- * through a rendered frame does not work: the transcript wraps a notice at two
85
- * columns wider than it displays it, so any phrase landing on a wrap boundary
86
- * loses a character or two on the way to `lastFrame()` and the assertion fails
87
- * for a reason that has nothing to do with the text.
88
- */
89
- export declare function vtInputDeadNotice(nodeVersion: string, fullscreen: boolean): string;
47
+ export declare function vtInputDeadNotice(nodeVersion: string): string;
90
48
  /**
91
49
  * `{ requested, resolved }` when the raise happened, `undefined` otherwise —
92
50
  * i.e. `cfg.transcriptRetainRequested` and `cfg.transcriptRetain`.
@@ -132,16 +90,6 @@ export interface MouseNoticeOptions {
132
90
  export interface VtInputNoticeOptions {
133
91
  /** `process.versions.node`, quoted back so the user can act on it. */
134
92
  nodeVersion: string;
135
- /**
136
- * Whether this run owns the alternate screen, i.e. `App`'s own `fullscreen`.
137
- *
138
- * Selects which half of the text applies: inline keeps the wheel and loses the
139
- * full-screen scroll keys, so it is told about `Shift+Tab` and nothing else
140
- * (see `vtInputDeadNotice`). Taken from the caller rather than re-derived here
141
- * for the same reason the version is — one source, no chance of the notice and
142
- * the renderer disagreeing about which mode is running.
143
- */
144
- fullscreen: boolean;
145
93
  /**
146
94
  * Record that `VT_INPUT_NOTICE_VERSION` was shown (`state.json`). Called once,
147
95
  * after dispatch — and it must write that constant, not `true`: the gate
@@ -26,8 +26,12 @@ import { getLogger } from '../logging/logger.js';
26
26
  *
27
27
  * `1` = the first text that names DRAG-SELECT and `/mouse`. `0` = the 0.6.2 text
28
28
  * that taught the Shift bypass, recorded back when this was a boolean.
29
+ * `2` = the Ctrl+C-to-copy text (tui-shift-enter-copy-queue 4.4): releasing
30
+ * no longer copies, and every user who learned the old gesture -- which is
31
+ * every user who has a version recorded at all -- has to be told the new
32
+ * one, once.
29
33
  */
30
- export const MOUSE_NOTICE_VERSION = 1;
34
+ export const MOUSE_NOTICE_VERSION = 2;
31
35
  /**
32
36
  * The one-shot mouse notice (tui-selection-and-scroll-follow §5.2 row 29).
33
37
  *
@@ -47,8 +51,8 @@ function mouseNoticeText(selectEnabled) {
47
51
  return ('Mouse wheel scrolls the transcript. Drag-select is off (mouseSelect); ' +
48
52
  '/mouse off hands the mouse back to your terminal for this session.');
49
53
  }
50
- return ('Mouse wheel scrolls the transcript, and dragging selects text - releasing ' +
51
- 'copies it. /mouse off hands the mouse back to your terminal for this session.');
54
+ return ('Mouse wheel scrolls the transcript; drag to select, then Ctrl+C to copy. ' +
55
+ '/mouse off hands the mouse back to your terminal for this session.');
52
56
  }
53
57
  /**
54
58
  * The §5.1 notice (tui-render-performance). Shown once per run, and only when
@@ -80,61 +84,11 @@ function retainRaisedNotice(requested, resolved) {
80
84
  * that offered only `/plan`, recorded back when this was a boolean.
81
85
  */
82
86
  export const VT_INPUT_NOTICE_VERSION = 1;
83
- /**
84
- * The `shift-tab-and-mouse-wheel-dead-on-some-terminals` notice (F1'). Shown at
85
- * most once per `VT_INPUT_NOTICE_VERSION`, and only on a Windows console that
86
- * cannot deliver either input — see `ui/win-vt-input.ts` for why that is a
87
- * property of the Node version rather than of the terminal.
88
- *
89
- * IT NAMES WHAT THE KEY DOES NOW, not merely that it is unavailable, and that
90
- * is the load-bearing half of the text. `Shift+Tab` on these machines is not
91
- * inert: the modifier is lost inside libuv, so Ink sees an ordinary `Tab` and
92
- * the `/` palette or an `@` popup completes its selection over the user's
93
- * draft. A user told only "this key does not work" keeps pressing it and keeps
94
- * losing text without ever connecting the two.
95
- *
96
- * THE FALLBACK KEY COMES BEFORE `/plan` IN BOTH BRANCHES, and it is named from
97
- * `MODE_TOGGLE_KEYS` rather than spelled out, so the four consumers of that
98
- * constant cannot drift apart. This notice is the last one of them to be wired
99
- * up, and it is also the only channel that reaches the affected user without
100
- * being asked to: the composer hint does not exist at all in inline mode and is
101
- * switched off by a short terminal or `hints: false` in full-screen, and
102
- * `/help` has to be typed by someone whose current belief is "the key does
103
- * nothing" rather than "I should read the help". Ordering follows cost — one
104
- * keystroke before four and a return.
105
- *
106
- * `Shift+Up` / `Shift+Down` is the scroll workaround named here because it is
107
- * the only one PROVEN to survive: both Node versions emit `\x1b[1;2A` for it,
108
- * byte for byte, in the probe data. `PgUp` / `PgDn` is expected to work too but
109
- * was never injected, so it is left out rather than promised.
110
- *
111
- * THE WHEEL HALF IS FULL-SCREEN ONLY, and dropping it inline is not a detail.
112
- * `Shift+Tab` is broken in both modes — the modifier dies inside libuv, well
113
- * below anything this app chooses — but the wheel is only ours to lose in
114
- * full-screen: inline never asks the console to report it (`mode` is a conjunct
115
- * of `cli.tsx::wantMouse`), so the wheel goes on scrolling the terminal's own
116
- * scrollback exactly as it always has. And `Shift+Up` / `Shift+Down` are handled
117
- * inside `App`'s `if (fullscreen)` branch, so recommending them inline names a
118
- * key that does nothing. Telling an inline user their working wheel is dead and
119
- * then handing them an inert workaround is the same failure this notice exists
120
- * to end — advice about a mode that is not running — merely pointed the other
121
- * way.
122
- *
123
- * EXPORTED FOR ITS TEST, unlike the two notices above it. Asserting this text
124
- * through a rendered frame does not work: the transcript wraps a notice at two
125
- * columns wider than it displays it, so any phrase landing on a wrap boundary
126
- * loses a character or two on the way to `lastFrame()` and the assertion fails
127
- * for a reason that has nothing to do with the text.
128
- */
129
- export function vtInputDeadNotice(nodeVersion, fullscreen) {
87
+ export function vtInputDeadNotice(nodeVersion) {
130
88
  const cause = `Node ${nodeVersion} on Windows does not turn on the console's VT input mode, so `;
131
89
  const cost = 'it will not switch mode, and with the / palette or an @ popup open it completes that ' +
132
90
  'instead, overwriting your draft. ';
133
91
  const toggle = `Press ${MODE_TOGGLE_KEYS.fallback} to switch mode instead (or /plan)`;
134
- if (!fullscreen) {
135
- return (`${cause}Shift+Tab never arrives: it comes through as a PLAIN TAB - ${cost}` +
136
- `${toggle}. Node 22.17.0+ (or 24.2.0+) restores it.`);
137
- }
138
92
  return (`${cause}two keys never arrive: the wheel sends nothing at all, and Shift+Tab arrives ` +
139
93
  `as a PLAIN TAB - ${cost}${toggle}, and Shift+Up / Shift+Down to scroll. ` +
140
94
  'Node 22.17.0+ (or 24.2.0+) restores both.');
@@ -199,7 +153,6 @@ export function useStartupNotices(dispatch, mouseNotice, retainNotice, vtInputNo
199
153
  const vtNoticeRef = useRef(vtInputNotice);
200
154
  vtNoticeRef.current = vtInputNotice;
201
155
  const vtNodeVersion = vtInputNotice?.nodeVersion;
202
- const vtFullscreen = vtInputNotice?.fullscreen ?? false;
203
156
  const vtNoticeShown = useRef(false);
204
157
  useEffect(() => {
205
158
  if (vtNodeVersion === undefined || vtNoticeShown.current)
@@ -210,10 +163,10 @@ export function useStartupNotices(dispatch, mouseNotice, retainNotice, vtInputNo
210
163
  dispatch({
211
164
  type: 'notice',
212
165
  level: 'warn',
213
- text: vtInputDeadNotice(vtNodeVersion, vtFullscreen),
166
+ text: vtInputDeadNotice(vtNodeVersion),
214
167
  });
215
168
  vtNoticeRef.current?.onSeen();
216
- }, [vtNodeVersion, vtFullscreen, dispatch]);
169
+ }, [vtNodeVersion, dispatch]);
217
170
  // A `warn` notice rather than a toast: it is about a value the user typed, so
218
171
  // it belongs in the transcript where it can be scrolled back to, not in a row
219
172
  // that expires. DEPENDS ON THE TWO SCALARS, never on the options object —
@@ -32,7 +32,7 @@ export declare const OVERLAY_PAGE = 8;
32
32
  export interface WheelRoutingOptions {
33
33
  isPointerCaptured?: () => boolean;
34
34
  isActive?: () => boolean;
35
- /** Absent when mouse support is off, in inline mode, or on a non-TTY. */
35
+ /** Absent when mouse support is off, or on a non-TTY. */
36
36
  mouseSource?: MouseSource;
37
37
  /** Read at EVENT time for the routing branch, and again at FLUSH time (P1-3). */
38
38
  getOverlay: () => Overlay | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.20",
59
+ "@aragon-agent/core": "^0.2.22",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",