@aragon-agent/cli 0.6.10 → 0.6.12

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.
@@ -1,37 +1,21 @@
1
- /**
2
- * Reading Enter frames back out of an Ink `input` string
3
- * (tui-shift-enter-copy-queue 3.5) -- pure.
4
- *
5
- * The stdin filter rewrites a modified Enter into `ENTER_NEWLINE_FRAME` inline,
6
- * in stream order, exactly the way it frames a paste. This module is the only
7
- * place that takes those frames apart again, and it handles BOTH newline
8
- * sources the composer can see in one `input` string:
9
- *
10
- * - `ENTER_NEWLINE_FRAME` -- Shift+Enter / Alt+Enter / CSI-u Enter, translated
11
- * by the filter; and
12
- * - a bare `\n` -- Ctrl+J, which Ink parses to `name: 'enter'` with
13
- * `input === '\n'` and NO `key.return`, so it reaches this branch as an
14
- * ordinary (control) character that must still mean "newline".
15
- *
16
- * I-12, THE BROADCAST OBLIGATION, APPLIES TO THESE FRAMES TOO. `useInput`
17
- * re-emits every chunk to every mounted handler, `stripPasteFrames` does not
18
- * know this frame, and a NUL appended into `SettingsScreen`'s API-key field
19
- * renders as nothing and silently breaks authentication -- the exact accident
20
- * class that function's own doc records. `stripEnterFrames` is the equivalent
21
- * guard for overlays with no paste model of their own.
22
- */
23
- import { type FrameSegment } from './paste-frames.js';
1
+ export type ComposerFrame = {
2
+ kind: 'text';
3
+ text: string;
4
+ } | {
5
+ kind: 'paste';
6
+ text: string;
7
+ } | {
8
+ kind: 'newline';
9
+ } | {
10
+ kind: 'submit';
11
+ };
24
12
  /** True when this `input` carries at least one Enter frame -- the branch guard. */
25
13
  export declare function hasEnterFrame(input: string): boolean;
26
14
  /**
27
- * Split an `input` string into ordered text runs and newline runs.
28
- *
29
- * Text-only by construction (paste frames are `mergeWithPasteRuns`'s job), so
30
- * the paste id never has to exist here -- `PromptInput` allocates ids for paste
31
- * segments while building the reducer's action, exactly as the paste branch
32
- * does (P1-5: the reducer stays pure and this stays a function of its input).
15
+ * Decode paste regions before interpreting CR/LF or newline frames as keys.
16
+ * Paste IDs remain the caller's responsibility.
33
17
  */
34
- export declare function splitEnterFrames(input: string): FrameSegment[];
18
+ export declare function splitEnterFrames(input: string): ComposerFrame[];
35
19
  /**
36
20
  * Merge newline runs with the paste frames that share the same `input`.
37
21
  *
@@ -48,7 +32,7 @@ export declare function splitEnterFrames(input: string): FrameSegment[];
48
32
  * Paste segments carry NO id here (P1-5); the caller assigns one per segment
49
33
  * only when it builds the reducer's action.
50
34
  */
51
- export declare function mergeWithPasteRuns(input: string, runs: FrameSegment[]): FrameSegment[];
35
+ export declare function mergeWithPasteRuns(input: string, runs: ComposerFrame[]): ComposerFrame[];
52
36
  /**
53
37
  * Remove every Enter frame for a consumer that has no newline model of its
54
38
  * own, replacing each frame with `'\n'` (tui-shift-enter-copy-queue 3.5, the
@@ -1,25 +1,3 @@
1
- /**
2
- * Reading Enter frames back out of an Ink `input` string
3
- * (tui-shift-enter-copy-queue 3.5) -- pure.
4
- *
5
- * The stdin filter rewrites a modified Enter into `ENTER_NEWLINE_FRAME` inline,
6
- * in stream order, exactly the way it frames a paste. This module is the only
7
- * place that takes those frames apart again, and it handles BOTH newline
8
- * sources the composer can see in one `input` string:
9
- *
10
- * - `ENTER_NEWLINE_FRAME` -- Shift+Enter / Alt+Enter / CSI-u Enter, translated
11
- * by the filter; and
12
- * - a bare `\n` -- Ctrl+J, which Ink parses to `name: 'enter'` with
13
- * `input === '\n'` and NO `key.return`, so it reaches this branch as an
14
- * ordinary (control) character that must still mean "newline".
15
- *
16
- * I-12, THE BROADCAST OBLIGATION, APPLIES TO THESE FRAMES TOO. `useInput`
17
- * re-emits every chunk to every mounted handler, `stripPasteFrames` does not
18
- * know this frame, and a NUL appended into `SettingsScreen`'s API-key field
19
- * renders as nothing and silently breaks authentication -- the exact accident
20
- * class that function's own doc records. `stripEnterFrames` is the equivalent
21
- * guard for overlays with no paste model of their own.
22
- */
23
1
  import { ENTER_NEWLINE_FRAME, PASTE_CLOSE, PASTE_OPEN } from '../input/limits.js';
24
2
  import { sanitisePaste } from '../input/paste-parse.js';
25
3
  import { sanitiseTyped } from './paste-frames.js';
@@ -28,10 +6,10 @@ export function hasEnterFrame(input) {
28
6
  return input.includes(ENTER_NEWLINE_FRAME);
29
7
  }
30
8
  /**
31
- * Split one Enter-frame-free span into ordered text / newline segments.
9
+ * Split one paste-free span into ordered text, newline and submit intents.
32
10
  *
33
11
  * Every newline source -- frame or bare `\n` -- yields ONE
34
- * `{ kind: 'text', text: '\n' }` segment, so the editor's existing `input`
12
+ * `{ kind: 'newline' }` segment, so the editor's existing `input`
35
13
  * action inserts it through the same path typing uses and the draft's row
36
14
  * budget applies unchanged. Non-newline text is accumulated and passed through
37
15
  * `sanitiseTyped` (I-14): a chunk that carries a frame can also carry the
@@ -51,13 +29,19 @@ function splitEnterSpan(span) {
51
29
  while (i < span.length) {
52
30
  if (span.startsWith(ENTER_NEWLINE_FRAME, i)) {
53
31
  pushText();
54
- segments.push({ kind: 'text', text: '\n' });
32
+ segments.push({ kind: 'newline' });
55
33
  i += ENTER_NEWLINE_FRAME.length;
56
34
  continue;
57
35
  }
36
+ if (span[i] === '\r') {
37
+ pushText();
38
+ segments.push({ kind: 'submit' });
39
+ i += 1;
40
+ continue;
41
+ }
58
42
  if (span[i] === '\n') {
59
43
  pushText();
60
- segments.push({ kind: 'text', text: '\n' });
44
+ segments.push({ kind: 'newline' });
61
45
  i += 1;
62
46
  continue;
63
47
  }
@@ -68,15 +52,12 @@ function splitEnterSpan(span) {
68
52
  return segments;
69
53
  }
70
54
  /**
71
- * Split an `input` string into ordered text runs and newline runs.
72
- *
73
- * Text-only by construction (paste frames are `mergeWithPasteRuns`'s job), so
74
- * the paste id never has to exist here -- `PromptInput` allocates ids for paste
75
- * segments while building the reducer's action, exactly as the paste branch
76
- * does (P1-5: the reducer stays pure and this stays a function of its input).
55
+ * Decode paste regions before interpreting CR/LF or newline frames as keys.
56
+ * Paste IDs remain the caller's responsibility.
77
57
  */
78
58
  export function splitEnterFrames(input) {
79
- return splitEnterSpan(input);
59
+ return input.includes(PASTE_OPEN)
60
+ ? mergeWithPasteRuns(input, []) : splitEnterSpan(input);
80
61
  }
81
62
  /**
82
63
  * Merge newline runs with the paste frames that share the same `input`.
@@ -6,6 +6,7 @@ function keyRows(times) {
6
6
  return [
7
7
  ['Enter', 'Submit (idle) / queue steering (running); re-pins to the newest output'],
8
8
  ['Alt+Enter / Shift+Enter', 'Insert newline'],
9
+ ['Ctrl+J', 'Newline fallback; /terminal-setup explains Shift+Enter binding'],
9
10
  ['Esc', 'Close menu / overlay'],
10
11
  ['Esc twice', 'Interrupt run (within 1.5s)'],
11
12
  ['Esc again', 'Force-stop after requesting interruption'],
@@ -30,6 +31,12 @@ function keyRows(times) {
30
31
  ['Run status row', 'Spinner and queue / interrupt keys, above the input during a run'],
31
32
  ['Drag text (left button)', 'Select text on screen; the highlight waits'],
32
33
  ['Ctrl+C (with a selection)', 'Copy the pending selection - does not arm exit'],
34
+ ['Native terminal selection', 'Copy-on-select is controlled by your terminal settings'],
35
+ ['Queue', 'Waits for a message receipt, not turn start; paused means not running'],
36
+ ['P>B / B>P', 'A pending mode switch; P=plan, B=build'],
37
+ ['E2 / E!', 'Esc twice to interrupt / Esc again to force-stop'],
38
+ ['S+ / T+', 'Services / TODO present; /bg and /todo status show counts'],
39
+ ['Queue(p) / (+99+)', 'Paused queue / at least 100 additional messages'],
33
40
  ['Up / Down', 'Prompt history (empty input)'],
34
41
  // Pasting is the one affordance here with no key of its own, so the only
35
42
  // place a user can learn what happened to their 218 lines is this row.
@@ -58,6 +65,7 @@ const COMMANDS = [
58
65
  ['/resume [file]', 'Load a saved session'],
59
66
  ['/copy', 'Copy the last answer to the clipboard (OSC 52, so it works over ssh)'],
60
67
  ['/mouse [on|off]', 'Release the mouse to your terminal, or take it back'],
68
+ ['/terminal-setup', 'Show newline key bindings without changing terminal settings'],
61
69
  ['/exit', 'Exit'],
62
70
  ];
63
71
  const SKILL_ROWS = [
@@ -27,9 +27,10 @@
27
27
  * same `decorate` call. What you see is what you get by construction, rather
28
28
  * than by a staleness check that can be wrong.
29
29
  */
30
+ import stripAnsi from 'strip-ansi';
30
31
  import { createScreenMirror } from './screen-mirror.js';
31
32
  import { paintRow, selectionOpen } from './highlight.js';
32
- import { isEmpty, normalize, rowSpan, selectedText, } from './selection.js';
33
+ import { isEmpty, normalize, rowSpan, selectedText, sliceColumns, } from './selection.js';
33
34
  /**
34
35
  * Motion coalescing window. The same 16 ms and the same reasoning as
35
36
  * `use-wheel-routing.ts`: this one is in the direct path of a hand gesture, so
@@ -67,10 +68,28 @@ export function createSelectionController(options) {
67
68
  let disposed = false;
68
69
  /** Guards `decorate` against being re-entered from inside a repaint. */
69
70
  let painting = false;
71
+ let notifiedHold = false;
72
+ let holdNotificationPending = false;
73
+ const notifyHold = () => {
74
+ if (disposed || notifiedHold === holdActive)
75
+ return;
76
+ notifiedHold = holdActive;
77
+ for (const listener of [...holdListeners])
78
+ listener(holdActive);
79
+ };
70
80
  const emitHold = (hold) => {
71
81
  holdActive = hold;
72
- for (const listener of [...holdListeners])
73
- listener(hold);
82
+ if (!painting) {
83
+ notifyHold();
84
+ return;
85
+ }
86
+ if (holdNotificationPending)
87
+ return;
88
+ holdNotificationPending = true;
89
+ queueMicrotask(() => {
90
+ holdNotificationPending = false;
91
+ notifyHold();
92
+ });
74
93
  };
75
94
  const clearFlushTimer = () => {
76
95
  if (!flushTimer)
@@ -97,13 +116,7 @@ export function createSelectionController(options) {
97
116
  holdWatchdog = null;
98
117
  if (!dragging)
99
118
  return;
100
- // Let go of `hold` and the drag; KEEP whatever was selected. The user may
101
- // still want to copy it, and throwing it away would turn a terminal quirk
102
- // into lost work.
103
- dragging = false;
104
- pendingFocus = null;
105
- clearFlushTimer();
106
- emitHold(false);
119
+ clear();
107
120
  }, holdMaxMs);
108
121
  holdWatchdog.unref?.();
109
122
  };
@@ -122,7 +135,7 @@ export function createSelectionController(options) {
122
135
  if (!painted && wanted)
123
136
  options.requestRedraw();
124
137
  };
125
- const clear = () => {
138
+ const clearState = () => {
126
139
  // Nothing to undo: no timers, no repaint, no redraw. This is what keeps
127
140
  // `onInvalidate` — which fires on every resize and every foreign write —
128
141
  // free for a session that has never selected anything.
@@ -139,7 +152,12 @@ export function createSelectionController(options) {
139
152
  // exit for two states, not one.
140
153
  if (holdActive)
141
154
  emitHold(false);
142
- paint(false);
155
+ };
156
+ const clear = () => {
157
+ const hadSelection = sel !== null || holdActive;
158
+ clearState();
159
+ if (hadSelection)
160
+ paint(false);
143
161
  };
144
162
  const cellOf = (event) => {
145
163
  // SGR reports are 1-based, frame line `i` is terminal row `i + 1`. A report
@@ -200,8 +218,7 @@ export function createSelectionController(options) {
200
218
  // a selection — N1 already declines auto-scroll-while-dragging. The notch
201
219
  // then scrolls normally, because the wheel router is a separate subscriber
202
220
  // on the same channel.
203
- if (dragging)
204
- clear();
221
+ clear();
205
222
  return;
206
223
  }
207
224
  if (event.kind === 'press') {
@@ -213,6 +230,7 @@ export function createSelectionController(options) {
213
230
  return;
214
231
  }
215
232
  const cell = cellOf(event);
233
+ settled = false;
216
234
  sel = { anchor: cell, focus: cell };
217
235
  pendingFocus = null;
218
236
  clearFlushTimer();
@@ -248,16 +266,35 @@ export function createSelectionController(options) {
248
266
  finishSelection();
249
267
  };
250
268
  const unsubscribe = options.source.subscribe(onMouse);
269
+ const selectedCellsChanged = (lines) => {
270
+ if (!settled || !sel)
271
+ return false;
272
+ const normal = normalize(sel);
273
+ for (let row = normal.start.row; row <= normal.end.row; row += 1) {
274
+ const span = rowSpan(normal, row);
275
+ if (!span)
276
+ continue;
277
+ const before = sliceColumns(stripAnsi(mirror.raw[row] ?? ''), span.from, span.to);
278
+ const after = sliceColumns(stripAnsi(lines[row] ?? ''), span.from, span.to);
279
+ if (before !== after)
280
+ return true;
281
+ }
282
+ return false;
283
+ };
251
284
  return {
252
285
  decorate(lines) {
253
- mirror.set(lines);
254
- if (painting || sel === null)
255
- return lines;
256
- const normal = normalize(sel);
257
- if (isEmpty(normal))
286
+ if (painting || disposed)
258
287
  return lines;
259
288
  painting = true;
260
289
  try {
290
+ if (selectedCellsChanged(lines))
291
+ clearState();
292
+ mirror.set(lines);
293
+ if (sel === null)
294
+ return lines;
295
+ const normal = normalize(sel);
296
+ if (isEmpty(normal))
297
+ return lines;
261
298
  const open = selectionOpen(options.theme(), options.caps);
262
299
  const cols = Math.max(1, options.cols());
263
300
  const out = lines.slice();
@@ -317,11 +354,11 @@ export function createSelectionController(options) {
317
354
  dispose() {
318
355
  if (disposed)
319
356
  return;
320
- disposed = true;
321
357
  // 4.2.3: a pending selection holds the viewport; teardown must let go
322
358
  // of it explicitly, exactly like `clear()` does.
323
359
  if (holdActive)
324
360
  emitHold(false);
361
+ disposed = true;
325
362
  unsubscribe();
326
363
  clearFlushTimer();
327
364
  clearWatchdog();
@@ -73,6 +73,8 @@ function serviceTextGlyph(status, exitCode, glyphs) {
73
73
  }
74
74
  function renderEntry(entry, glyphs) {
75
75
  switch (entry.kind) {
76
+ case 'queued':
77
+ return block('Queued but never sent:', entry.text);
76
78
  case 'user':
77
79
  return block(glyphs.user, entry.text);
78
80
  case 'assistant': {
@@ -211,20 +213,26 @@ export function renderTranscriptText(entries, opts) {
211
213
  lines.push(`${g.ellipsis} ${dropped} earlier ${dropped === 1 ? 'entry' : 'entries'} dropped by ` +
212
214
  `transcriptRetain ${g.midDot} raise it or /save sooner`);
213
215
  }
214
- if (entries.length > max && max > 1) {
215
- const head = Math.ceil(max / 2);
216
- const tail = max - head;
217
- const omitted = entries.length - max;
218
- for (const entry of entries.slice(0, head))
219
- lines.push(...renderEntry(entry, g));
220
- lines.push(`${g.ellipsis} ${omitted} entries omitted ${g.midDot} use /save before exiting`);
221
- for (const entry of entries.slice(entries.length - tail)) {
216
+ const historyCount = entries.filter((entry) => entry.kind !== 'queued').length;
217
+ const omitted = max > 1 ? Math.max(0, historyCount - max) : 0;
218
+ const head = Math.ceil(max / 2);
219
+ const tailStart = historyCount - (max - head);
220
+ let historyIndex = 0;
221
+ let reported = false;
222
+ for (const entry of entries) {
223
+ if (entry.kind === 'queued') {
222
224
  lines.push(...renderEntry(entry, g));
225
+ continue;
223
226
  }
224
- }
225
- else {
226
- for (const entry of entries)
227
- lines.push(...renderEntry(entry, g));
227
+ const index = historyIndex++;
228
+ if (omitted > 0 && index >= head && index < tailStart) {
229
+ if (!reported) {
230
+ lines.push(`${g.ellipsis} ${omitted} entries omitted ${g.midDot} use /save before exiting`);
231
+ reported = true;
232
+ }
233
+ continue;
234
+ }
235
+ lines.push(...renderEntry(entry, g));
228
236
  }
229
237
  return `${lines.join('\n')}\n`;
230
238
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
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.22",
59
+ "@aragon-agent/core": "^0.2.24",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",