@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
@@ -289,6 +289,29 @@ function nextId(state) {
289
289
  function mapEntry(entries, id, fn) {
290
290
  return entries.map((e) => (e.id === id ? fn(e) : e));
291
291
  }
292
+ /**
293
+ * Rewrite every `queued` entry in place as the user message it became
294
+ * (tui-shift-enter-copy-queue 5.2.3).
295
+ *
296
+ * SAME ID, NEW OBJECT -- the `mapEntry` discipline for a non-append change:
297
+ * the position (and therefore the settled boundary, which advances by
298
+ * POSITION and never by identity) is untouched, while the new object
299
+ * reference is what lets `React.memo` and the height cache see that this
300
+ * one row changed. ALL of them at once, because `drainSteering` is a full
301
+ * drain: converting only some would show a partial acceptance that never
302
+ * happened. Identity-preserving when nothing is queued -- `turnStart` fires
303
+ * every turn, and this must stay free for the common case.
304
+ */
305
+ function convertQueuedEntries(entries) {
306
+ let touched = false;
307
+ const next = entries.map((e) => {
308
+ if (e.kind !== 'queued')
309
+ return e;
310
+ touched = true;
311
+ return { id: e.id, kind: 'user', text: e.text };
312
+ });
313
+ return touched ? next : entries;
314
+ }
292
315
  /**
293
316
  * Seal `thinkingMs`, once, as a spreadable patch (§3.1.2).
294
317
  *
@@ -382,31 +405,6 @@ function settleRetryCard(state, entries, phase) {
382
405
  retryEntryId: undefined,
383
406
  };
384
407
  }
385
- /**
386
- * EVERY PATH THAT REACHES `status: 'idle'` RELEASES THE LIVE TAIL (D-35 / AC-40).
387
- *
388
- * `runEnd` finalizes only the streaming ASSISTANT entry and `abortMark` maps only
389
- * assistant entries, so neither touches a tool entry stranded at
390
- * `status: 'running'` -- the engine's own idle watchdog, a provider error mid-call
391
- * and `Esc` all produce one. Today that costs a single spinner row. With a tail
392
- * attached it costs a permanent multi-row card whose stall counter is frozen at
393
- * its last value, because the 200 ms ticker that feeds `nowSec` is torn down the
394
- * instant `state.status` leaves `running` -- and, worse, `computeSettledCount`
395
- * breaks on a non-settled tool entry, so that card pins `Transcript`'s MONOTONIC
396
- * settled boundary and re-renders the tail every frame for the rest of the
397
- * session. The rule and the stakes are `settleRetryCard`'s, verbatim.
398
- *
399
- * THE STATUS IS DELIBERATELY NOT CHANGED. That is a different decision with
400
- * different consequences for `bashBadge` and for the run's own bookkeeping, and
401
- * it is not this round's to make. Only the tail is released.
402
- *
403
- * `liveSeq` is BUMPED rather than cleared, and that is not bookkeeping: clearing
404
- * `live` is a non-append mutation, and with the status untouched nothing else in
405
- * the tool revision moves -- so without the bump the height cache would keep
406
- * serving the multi-row measurement for a card that now draws one row (I-L3-1).
407
- * Entries with no tail are returned by identity, which is what keeps
408
- * `EntryView`'s memo boundary intact for the rest of the transcript.
409
- */
410
408
  function releaseLiveTails(entries) {
411
409
  let touched = false;
412
410
  const next = entries.map((e) => {
@@ -450,6 +448,15 @@ export function viewReducer(state, action) {
450
448
  aborted: false,
451
449
  };
452
450
  }
451
+ case 'steerQueued': {
452
+ const { id, seq } = nextId(state);
453
+ const entry = { id, kind: 'queued', text: action.text };
454
+ // NOTHING ELSE RESETS. This is not a new turn -- `turnProduced`,
455
+ // `errorNoticed` and `aborted` describe the run that is still in
456
+ // flight, and clearing them here would let the next `runEnd` failure
457
+ // guard misfire on a turn that produced plenty.
458
+ return { ...state, seq, ...appendEntry(state, state.entries, entry) };
459
+ }
453
460
  case 'runStart':
454
461
  return {
455
462
  ...state,
@@ -460,6 +467,14 @@ export function viewReducer(state, action) {
460
467
  };
461
468
  case 'turnStart': {
462
469
  const { id, seq } = nextId(state);
470
+ // CONVERT, THEN APPEND, IN THAT ORDER AND IN THIS ONE ACTION
471
+ // (5.2.3): `turn_start` is the authoritative "the steering queue was
472
+ // drained into the conversation" signal (the loop drains at its top,
473
+ // immediately before emitting it), so the user message and the answer
474
+ // that follows it must land in the same commit, in render order, or a
475
+ // frame can exist where the message has vanished and the answer has
476
+ // not appeared.
477
+ const entries = convertQueuedEntries(state.entries);
463
478
  const entry = {
464
479
  id,
465
480
  kind: 'assistant',
@@ -468,7 +483,7 @@ export function viewReducer(state, action) {
468
483
  thinkingOpen: false,
469
484
  streaming: true,
470
485
  };
471
- return { ...state, seq, ...appendEntry(state, state.entries, entry), streamingId: id };
486
+ return { ...state, seq, ...appendEntry(state, entries, entry), streamingId: id };
472
487
  }
473
488
  case 'thinkingStart': {
474
489
  if (!state.streamingId)
@@ -1218,22 +1233,6 @@ export function viewReducer(state, action) {
1218
1233
  entries: mapEntry(state.entries, known, (e) => e.kind === 'retry' ? { ...e, phase: 'retrying', resumeAt: undefined } : e),
1219
1234
  };
1220
1235
  }
1221
- /**
1222
- * Rewind the view for a mid-stream restart.
1223
- *
1224
- * THE ONLY ACTION IN THIS REDUCER THAT SHRINKS `entries`, and it is safe for
1225
- * one specific reason (§6.4 / AC-22). `Transcript` clamps its settled boundary
1226
- * with a MONOTONIC high-water mark: if `highWater` ever exceeded
1227
- * `entries.length` the clamp would mark EVERYTHING settled — including the
1228
- * still-streaming assistant entry — and `<Static>` would duplicate it.
1229
- *
1230
- * It cannot, and the ORDERING is why: the entries removed here are
1231
- * `kind: 'tool'` cards created by `toolCallStart` AFTER `turnStart` created the
1232
- * streaming assistant entry, so they sit strictly above it in the array; and
1233
- * the boundary scan already breaks at that entry. Removing only entries above
1234
- * the boundary is the invariant. A future action that removed a SETTLED entry
1235
- * would break `<Static>` and nothing would report it.
1236
- */
1237
1236
  case 'streamRestart': {
1238
1237
  const discarded = new Set(action.discardedToolCallIds);
1239
1238
  const entries = state.entries.filter((e) => !(e.kind === 'tool' && discarded.has(e.toolCallId)));
@@ -1326,18 +1325,6 @@ export function viewReducer(state, action) {
1326
1325
  droppedEntries: appended.droppedEntries,
1327
1326
  };
1328
1327
  }
1329
- /**
1330
- * Rewrite the LIVE card in place.
1331
- *
1332
- * SAFE ONLY BECAUSE OF D-11'S OTHER HALF: a `ready` / `running` card has
1333
- * reached `<Static>` and cannot be re-printed, so `serviceEnd` appends a new
1334
- * entry instead of routing through here. What this handles is the window in
1335
- * which the card is still in the live region — `starting`, and the one
1336
- * transition out of it — plus the output tail while it is there.
1337
- *
1338
- * The lookup is by `serviceId` and SKIPS terminal entries, so a stray update
1339
- * cannot rewrite the one-row record `serviceEnd` appended.
1340
- */
1341
1328
  case 'serviceUpdate': {
1342
1329
  const target = findServiceEntryId(state, action.service.id);
1343
1330
  if (!target)
@@ -1347,15 +1334,6 @@ export function viewReducer(state, action) {
1347
1334
  entries: mapEntry(state.entries, target, (e) => e.kind === 'service' ? { ...e, ...serviceFields(action.service) } : e),
1348
1335
  };
1349
1336
  }
1350
- /**
1351
- * A service reached a terminal state.
1352
- *
1353
- * TWO WRITES, AND BOTH ARE REQUIRED. The live card is settled in place so
1354
- * `computeSettledCount` can move past it and `estimateEntryRows` stops
1355
- * charging for a tail nothing will add to; the NEW one-row entry is what the
1356
- * user actually reads, because the card may already be printed into
1357
- * `<Static>` and `<Static>` cannot un-print (D-11 / R-13).
1358
- */
1359
1337
  case 'serviceEnd': {
1360
1338
  const target = findServiceEntryId(state, action.service.id);
1361
1339
  const settled = target
package/dist/cli.js CHANGED
@@ -15,7 +15,8 @@ import { dirname, join } from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import process from 'node:process';
17
17
  import '../runtime/insecure-tls-warning.cjs';
18
- import { render } from 'ink';
18
+ // Must precede every other dependency that imports Ink: CI detection is cached.
19
+ import { render } from './ui/ink-runtime.js';
19
20
  import { Command } from 'commander';
20
21
  import { loadConfig } from './config/load.js';
21
22
  import { getConfigPath, readConfigFile, updatePersistedConfig } from './config/store.js';
@@ -40,7 +41,6 @@ import { registerExecCommand } from './exec/cli-commands.js';
40
41
  import { registerSessionsCommand } from './session/cli-commands.js';
41
42
  import { registerDoctorCommand, registerInfoCommand } from './diagnostics/cli-commands.js';
42
43
  import { App } from './ui/App.js';
43
- import { decideRenderMode } from './ui/layout/frame.js';
44
44
  import { createFrameDiffer } from './ui/frame-differ.js';
45
45
  import { wrapStdoutForFrames } from './ui/stdout-frame-writer.js';
46
46
  import { setFrameStatsProvider } from './commands/perf.js';
@@ -56,7 +56,6 @@ import { renderTranscriptText } from './ui/transcript-text.js';
56
56
  import { detectCapabilities } from './ui/capabilities.js';
57
57
  import { pickGlyphs } from './ui/glyphs.js';
58
58
  import { getTheme } from './ui/theme.js';
59
- import { copyText } from './ui/clipboard.js';
60
59
  import { createSelectionController, } from './ui/selection/selection-controller.js';
61
60
  import { DENY_ALL_HUMAN_INPUT } from './tools/human-input.js';
62
61
  // A VALUE import, and the one deliberate exception to the paragraph above
@@ -96,7 +95,6 @@ function toFlags(opts) {
96
95
  confirm: opts.confirm,
97
96
  toolTimeout: opts.toolTimeout,
98
97
  idleTimeout: opts.idleTimeout,
99
- fullscreen: opts.fullscreen,
100
98
  exitTranscript: opts.exitTranscript,
101
99
  compact: opts.compact,
102
100
  hints: opts.hints,
@@ -230,14 +228,8 @@ function makeController(flags, opts = { interactive: true }) {
230
228
  // resolved a bootstrap level — it ran before commander and before `.env` were
231
229
  // readable — and without this hand-off the fully resolved one never applies.
232
230
  getLogger().reconfigure(config.log);
233
- // Whether this session could EVER show the todo rail (todo-plan-execution
234
- // §3.7 / P1-6). It varies ONE sentence of `<todo_planning>`; `decideRenderMode`
235
- // is pure and `runInteractive` calls it again with the same inputs, so
236
- // computing it here rather than threading it back costs nothing and keeps the
237
- // controller's construction self-contained.
238
- const todoPanelCapable = opts.interactive &&
239
- decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout) ===
240
- 'fullscreen';
231
+ // Headless controllers never expose the task panel.
232
+ const todoPanelCapable = opts.interactive;
241
233
  const confirmBridge = { handler: null };
242
234
  // `cancelPending` is replaced by the App's own implementation on mount; this
243
235
  // no-op keeps the object total so nothing has to null-check it.
@@ -389,11 +381,15 @@ function forceVtInputForThisConsole() {
389
381
  * renders Ink, `runOneShot()` goes to `runHeadless()`, which never does.
390
382
  */
391
383
  function runInteractive(flags, extras = {}) {
384
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
385
+ process.stderr.write('Interactive UI requires a TTY. Use -p "<prompt>", exec, or config list / config set.\n');
386
+ process.exitCode = 2;
387
+ return;
388
+ }
392
389
  const { controller, confirmBridge, humanInputBridge } = makeController(flags);
393
390
  const config = controller.getConfig();
394
- const mode = decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout);
395
391
  const logger = getLogger();
396
- logger.info('cli', 'run_interactive', { mode, provider: config.provider, model: config.model });
392
+ logger.info('cli', 'run_interactive', { mode: 'fullscreen', provider: config.provider, model: config.model });
397
393
  attachAgentEvents(logger, controller);
398
394
  // The team stream is CLI-local (D-10), so `attachAgentEvents` cannot see it.
399
395
  // Without this a dispatch leaves NO trace in the log file at all — the wrong
@@ -442,8 +438,7 @@ function runInteractive(flags, extras = {}) {
442
438
  // a console this process just repaired.
443
439
  const vtInputByNode = supportsWindowsVtInput(process.platform, process.versions.node);
444
440
  const vtInputSupported = vtInputByNode || forceVtInputForThisConsole();
445
- const wantMouse = mode === 'fullscreen' &&
446
- config.mouse &&
441
+ const wantMouse = config.mouse &&
447
442
  !!process.stdout.isTTY &&
448
443
  !!process.stdin.isTTY &&
449
444
  vtInputSupported;
@@ -492,21 +487,13 @@ function runInteractive(flags, extras = {}) {
492
487
  onCopied: null,
493
488
  requestRedraw: null,
494
489
  };
495
- // --- Frame differ (tui-input-flicker-fix §3.2 / §4.2). -------------------
496
- //
497
- // FULL-SCREEN ONLY, and that is invariant I-3 rather than caution: inline mode
498
- // prints settled entries through Ink's `<Static>`, starts wherever the shell's
499
- // cursor happened to be and scrolls, so row k of a frame is not absolute row k
500
- // there and never will be. `--no-diff-render` skips construction entirely,
501
- // which is what makes rung 2 of the fail-safe ladder byte-identical to a
502
- // pre-fix build rather than merely equivalent.
503
- //
504
- // `rows` is a THUNK, not a snapshot: it is the only input to the I-9 geometry
505
- // guard and it changes under the differ's feet on every resize.
506
- const frameWriter = mode === 'fullscreen' && config.diffRender
490
+ const frameWriter = config.diffRender
507
491
  ? wrapStdoutForFrames(process.stdout, createFrameDiffer({
508
492
  sync: config.syncOutput && process.env.TERM !== 'dumb',
509
493
  rows: () => process.stdout.rows,
494
+ // Rows that fill the line skip `CSI K` so the scrollbar's last column
495
+ // is not erased on pending-wrap terminals (frame-differ I-4).
496
+ cols: () => process.stdout.columns,
510
497
  // The console bridge (`App.tsx`, full-screen only) turns this into
511
498
  // `dispatch({type:'notice', level:'warn'})`, which is the only legal
512
499
  // user-visible channel with the TUI mounted — a direct write would
@@ -539,11 +526,11 @@ function runInteractive(flags, extras = {}) {
539
526
  // than printing zeroes that read like a writer doing nothing (§5.4).
540
527
  if (frameWriter)
541
528
  setFrameStatsProvider(frameWriter.stats);
542
- const frameObserver = mode === 'fullscreen' ? createFrameObserver({
529
+ const frameObserver = createFrameObserver({
543
530
  stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
544
- }) : null;
531
+ });
545
532
  const disposeFrameWriter = () => {
546
- frameObserver?.dispose();
533
+ frameObserver.dispose();
547
534
  pointerRouter?.dispose();
548
535
  scrollbar.controller?.dispose();
549
536
  if (!frameWriter)
@@ -552,7 +539,7 @@ function runInteractive(flags, extras = {}) {
552
539
  frameWriter.dispose();
553
540
  };
554
541
  const replayTranscript = () => {
555
- if (replayed || mode !== 'fullscreen' || !config.exitTranscript)
542
+ if (replayed || !config.exitTranscript)
556
543
  return;
557
544
  replayed = true;
558
545
  const snapshot = readExitSnapshot();
@@ -571,7 +558,7 @@ function runInteractive(flags, extras = {}) {
571
558
  ...(snapshot.droppedEntries ? { droppedEntries: snapshot.droppedEntries } : {}),
572
559
  }));
573
560
  };
574
- if (mode === 'fullscreen') {
561
+ {
575
562
  // `mouse` here means "a mouse-parsing filter is installed", NOT "the user
576
563
  // wants mouse support" — see `AltScreenOptions`. `screen.ts` must never
577
564
  // derive it for itself, or a later refactor re-opens the gap I-8 closes.
@@ -585,7 +572,7 @@ function runInteractive(flags, extras = {}) {
585
572
  screen = enterAltScreen(process.stdout, {
586
573
  mouse: mouseOn,
587
574
  motion: mouseOn,
588
- bracketedPaste: pasteOn && mode === 'fullscreen',
575
+ bracketedPaste: pasteOn,
589
576
  });
590
577
  const restore = () => screen?.restore();
591
578
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
@@ -610,42 +597,6 @@ function runInteractive(flags, extras = {}) {
610
597
  process.exit(128 + signo);
611
598
  });
612
599
  }
613
- // --- Auto-update (cli-auto-update section 3.8). --------------------------
614
- //
615
- // THE ONLY CONSTRUCTION SITE. `runOneShot` / `runHeadless` never reach this
616
- // function, so `aragon -p` executes zero update code and its stdout is
617
- // byte-identical to a pre-feature build (AC-1); `aragon config <subcommand>`,
618
- // `aragon logs`, `aragon skills` and `aragon --version` likewise. Bare
619
- // `aragon config` DOES construct it, because it is a full interactive session
620
- // with a settings overlay on top - which is correct, and is why AC-1's claim
621
- // names the SUBCOMMAND form.
622
- //
623
- // `CI` is excluded on top of the two TTY tests because a CI runner with a pty
624
- // allocated is otherwise a machine that silently mutates its own global
625
- // toolchain mid-pipeline (D-11).
626
- // --- The foreign-write door (§4.4.5). ------------------------------------
627
- //
628
- // Where bytes that are OURS but are NOT a frame go — today, the OSC 52
629
- // clipboard sequence. THREE SHAPES, ONE DOOR, and the third one is the reason
630
- // this is a resolved value rather than an inline arrow:
631
- //
632
- // * with the differ in front of stdout it MUST be `frameWriter.writeForeign`,
633
- // which invalidates the cache and skips the fallback accounting — through
634
- // the proxy the same bytes are an unrecognised chunk, which is
635
- // `passThrough(true)`, which prints `FRAME_FALLBACK_NOTICE` at a user who
636
- // did nothing but copy something (P1-6);
637
- // * without one — `--no-diff-render`, and inline mode, which never builds a
638
- // differ at all — stdout IS the real stream, so a direct write is both safe
639
- // and what §4.4.5 prescribes. OSC 52 emits no newline, so Ink's own line
640
- // accounting is untouched and there is nothing to invalidate;
641
- // * on a non-TTY there is no terminal to accept it, so the door is ABSENT.
642
- //
643
- // It must never be a function that accepts a chunk and writes NOWHERE.
644
- // `copyText` reports `'osc52'` whenever its door took the text, so a silent
645
- // no-op door — which `frameWriter?.writeForeign(chunk)` is under
646
- // `--no-diff-render` — makes the toast name a mechanism that was never
647
- // attempted. Naming the mechanism honestly is the entire reason that return
648
- // value exists (R-7), and a door that lies is worse than no door.
649
600
  const writeForeign = frameWriter
650
601
  ? (text) => { frameObserver?.invalidate(); frameWriter.writeForeign(text); }
651
602
  : process.stdout.isTTY
@@ -678,10 +629,10 @@ function runInteractive(flags, extras = {}) {
678
629
  // correct. That is rung 2 of the fail-safe ladder doing its job.
679
630
  repaint: () => frameWriter?.repaint() ?? false,
680
631
  requestRedraw: () => selectionBridge.requestRedraw?.(),
681
- // `?? undefined` and not a wrapper: `copyText` skips OSC 52 entirely when
682
- // it has no door, and reports `'native'`/`'none'` accordingly.
683
- copy: (text) => copyText(text, writeForeign ?? undefined),
684
- onCopied: (via, lines, chars) => selectionBridge.onCopied?.(via, lines, chars),
632
+ // The copy decision moved UP to `App` (tui-shift-enter-copy-queue
633
+ // 4.2.4): Ctrl+C with a settled selection is routed there, through
634
+ // the SAME `copyText` door and the SAME bridge `onCopied` toast
635
+ // funnel -- the controller keeps only the selection state machine.
685
636
  // Read at PAINT time, not captured: `/theme` rebuilds the theme mid-session.
686
637
  theme: () => getTheme(config.theme, detectCapabilities(process.env, process.stdout)),
687
638
  caps: detectCapabilities(process.env, process.stdout),
@@ -695,45 +646,30 @@ function runInteractive(flags, extras = {}) {
695
646
  selectionBridge.controller?.dispose();
696
647
  selectionBridge.controller = null;
697
648
  };
698
- /**
699
- * The terminal-facing services `App` cannot build for itself (§4.4).
700
- *
701
- * PRESENT IN INLINE MODE TOO, and only for the foreign-write door: §4.4.5
702
- * routes `/copy` through `ui/clipboard.ts` precisely so it gains OSC 52 — the
703
- * one mechanism that reaches the clipboard of the machine the USER is sitting
704
- * at over SSH — and gating the whole bridge on full-screen would have handed
705
- * that back only to full-screen users while the command is available in both.
706
- * Every OTHER field stays absent there, so the inline tree is unchanged: there
707
- * is no filter, hence no `setMouseCapture` and no `ctx.mouse`; `mouseSelect`
708
- * is `wantSelect`, which is already false without one; and there is no
709
- * controller, so no `selection`.
710
- */
711
- const terminalBridge = mode === 'fullscreen' || writeForeign
712
- ? {
713
- mouseSelect: wantSelect,
714
- ...(mode === 'fullscreen' ? { scrollbar } : {}),
715
- ...(mouseOn
716
- ? {
717
- setMouseCapture: (on) => {
718
- screen?.setMouseCapture(on);
719
- mouseCaptured = on;
720
- if (!on)
721
- scrollbar.controller?.cancel();
722
- // A released mouse cannot be dragging, and a selection left
723
- // painted over a screen the terminal is now selecting on its
724
- // own would be two highlights claiming the same rows.
725
- if (!on)
726
- selectionBridge.controller?.clear();
727
- },
728
- isMouseCaptured: () => mouseCaptured,
729
- }
730
- : {}),
731
- // The same door the controller copies through, so `/copy` and a
732
- // drag-release can never disagree about which stream OSC 52 goes to.
733
- ...(writeForeign ? { writeForeign } : {}),
734
- ...(selectionBridge.controller ? { selection: selectionBridge } : {}),
735
- }
736
- : undefined;
649
+ const terminalBridge = {
650
+ mouseSelect: wantSelect,
651
+ scrollbar,
652
+ ...(mouseOn
653
+ ? {
654
+ setMouseCapture: (on) => {
655
+ screen?.setMouseCapture(on);
656
+ mouseCaptured = on;
657
+ if (!on)
658
+ scrollbar.controller?.cancel();
659
+ // A released mouse cannot be dragging, and a selection left
660
+ // painted over a screen the terminal is now selecting on its
661
+ // own would be two highlights claiming the same rows.
662
+ if (!on)
663
+ selectionBridge.controller?.clear();
664
+ },
665
+ isMouseCaptured: () => mouseCaptured,
666
+ }
667
+ : {}),
668
+ // The same door the controller copies through, so `/copy` and a
669
+ // drag-release can never disagree about which stream OSC 52 goes to.
670
+ ...(writeForeign ? { writeForeign } : {}),
671
+ ...(selectionBridge.controller ? { selection: selectionBridge } : {}),
672
+ };
737
673
  const updateBridge = { service: null, onAttach: null };
738
674
  let updateService = null;
739
675
  let interactiveClosed = false;
@@ -761,12 +697,7 @@ function runInteractive(flags, extras = {}) {
761
697
  config.update.mode !== 'off';
762
698
  let instance;
763
699
  try {
764
- instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay,
765
- // `mouseOn`, NOT the handle (I-11). `App.tsx:186-188` documents this
766
- // prop's contract as "absent under `--no-mouse`, inline, or non-TTY", and
767
- // it feeds `enabled: !!mouseSource` on a wheel subscription that would
768
- // otherwise be a channel no event can ever arrive on.
769
- mouseSource: mouseOn && filter ? filter.source : undefined,
700
+ instance = render(_jsx(App, { controller: controller, version: VERSION, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseOn && filter ? filter.source : undefined,
770
701
  // Present ONLY when this console swallows `CSI Z` and mouse reports.
771
702
  // Passed in rather than probed inside `App` so that every test renders
772
703
  // the same tree on every machine — reading `process.versions.node` from
@@ -780,17 +711,11 @@ function runInteractive(flags, extras = {}) {
780
711
  // straight to stdout and permanently shifts the fixed frame's accounting.
781
712
  {
782
713
  exitOnCtrlC: false,
783
- patchConsole: mode === 'inline',
714
+ patchConsole: false,
784
715
  // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
785
716
  // every existing path is provably unchanged.
786
717
  stdin: filter?.stdin ?? process.stdin,
787
- // SPREAD, not `frameWriter?.stdout ?? process.stdout`. This call passed no
788
- // `stdout` at all before, and Ink defaults it to `process.stdout`; omitting
789
- // the key entirely when the writer is off keeps the options object the same
790
- // SHAPE it has always had, which is what makes AC-5 / AC-6 ("byte-identical
791
- // with --no-diff-render, inline mode and `aragon -p`") a statement about
792
- // the code rather than about Ink's defaulting.
793
- ...(frameObserver ? { stdout: frameObserver.stdout } : {}),
718
+ stdout: frameObserver.stdout,
794
719
  });
795
720
  }
796
721
  catch (error) {
@@ -968,7 +893,6 @@ const CONFIG_SET_KEYS = new Set([
968
893
  'confirmTools',
969
894
  'toolTimeoutMs',
970
895
  'idleTimeoutMs',
971
- 'fullscreen',
972
896
  'exitTranscript',
973
897
  'transcriptWindow',
974
898
  // tui-render-performance L1 / L4. Listed here AND cased in the switch
@@ -1189,9 +1113,6 @@ function runConfigSet(key, value) {
1189
1113
  case 'idleTimeoutMs':
1190
1114
  patch.idleTimeoutMs = coercePositiveInt(value, 210_000);
1191
1115
  break;
1192
- case 'fullscreen':
1193
- patch.fullscreen = value === 'true' || value === '1';
1194
- break;
1195
1116
  case 'exitTranscript':
1196
1117
  patch.exitTranscript = value === 'true' || value === '1';
1197
1118
  break;
@@ -1480,31 +1401,17 @@ function buildProgram() {
1480
1401
  .option('--tool-timeout <ms>', 'Per-tool executor ceiling (default 180000)')
1481
1402
  .option('--idle-timeout <ms>', 'Watchdog idle timeout (auto-raised to >= tool-timeout+30s)')
1482
1403
  .option('--theme <name>', 'auto|warm|cool|light ("dark" is an alias for "cool")')
1483
- // Same tri-state shape as `--fullscreen`: declaring the positive form first
1484
- // keeps the default `undefined`, so "no opinion" stays distinguishable from
1485
- // an explicit choice and the config layer below can still win.
1486
1404
  .option('--compact', 'Compact transcript density (no blank rows between turns)')
1487
1405
  .option('--no-compact', 'Comfortable transcript density')
1488
1406
  .option('--hints', 'Always show the composer hint row')
1489
1407
  .option('--no-hints', 'Hide the composer hint row')
1490
- // Positive form first, same tri-state reason as `--fullscreen` above.
1491
1408
  .option('--show-thinking', 'Show the reasoning blocks the model returns')
1492
1409
  .option('--no-show-thinking', 'Hide reasoning blocks (the default)')
1493
- // Positive form first, same tri-state reason as `--fullscreen` above. The
1494
- // pair matters more here than for its neighbour because this key defaults to
1495
- // ON: a lone negative form would let commander synthesize `true` and
1496
- // overwrite a stored `false` on every run that passed no flag at all.
1497
1410
  .option('--live-tool-output', 'Show a running command output tail (the default)')
1498
1411
  .option('--no-live-tool-output', 'Keep a running tool card to one line')
1499
- // Positive form first, same tri-state reason as `--fullscreen` above.
1500
1412
  .option('--mouse', 'Wheel scrolls the transcript (full-screen mode)')
1501
1413
  .option('--no-mouse', 'Leave the mouse to the terminal (the wheel does nothing)')
1502
- // Positive form FIRST, for the tri-state reason `--fullscreen` records
1503
- // below: `mouseSelect` is persisted and defaults to `true`, so a lone
1504
- // `--no-mouse-select` would make commander default `opts.mouseSelect` to
1505
- // `true` — indistinguishable from silence, and silently overriding a stored
1506
- // `false` on every run that passes no flag at all.
1507
- .option('--mouse-select', 'Drag with the mouse to select text; releasing copies it')
1414
+ .option('--mouse-select', 'Drag with the mouse to select text; Ctrl+C copies it')
1508
1415
  .option('--no-mouse-select', 'Keep wheel scrolling, but leave drag-select off')
1509
1416
  // Positive form FIRST, same tri-state reason as `--mouse-select` above:
1510
1417
  // `paste` is persisted and defaults to `true`, so a lone `--no-paste` would
@@ -1512,16 +1419,7 @@ function buildProgram() {
1512
1419
  // silence, and silently overriding a stored `false` on every run.
1513
1420
  .option('--paste', 'Collapse large pastes into a placeholder (the default)')
1514
1421
  .option('--no-paste', 'Treat pasted bytes as keystrokes (pre-0.6.3 behavior)')
1515
- // Declaring `--fullscreen` BEFORE `--no-fullscreen` keeps the default
1516
- // `undefined` instead of `true`, which is what makes the tri-state work:
1517
- // "no opinion" must stay distinguishable from "force it".
1518
- .option('--fullscreen', 'Force the full-screen TUI (overrides auto-downgrade, except non-TTY)')
1519
- .option('--no-fullscreen', 'Force the inline renderer (v0.2.0 behavior)')
1520
1422
  .option('--no-exit-transcript', 'Do not replay the session summary after exiting')
1521
- // Same tri-state shape as `--fullscreen`: the positive form is declared
1522
- // FIRST so the default stays `undefined` and "no opinion" remains
1523
- // distinguishable from an explicit `--no-plan`, which is what lets the
1524
- // config layer win when neither flag was given.
1525
1423
  .option('--plan', 'Start the session in PLAN mode (read-only research + review)')
1526
1424
  .option('--no-plan', 'Start the session in BUILD mode (overrides planModeDefault)')
1527
1425
  // BOTH FORMS, positive first, and the pair is mandatory (team-subagents
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
3
  * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
- * /resume /copy /exit (/quit), plus the /team, /fast and /todo subsystems.
4
+ * /resume /copy /terminal-setup /exit (/quit), plus the /team, /fast and
5
+ * /todo subsystems.
5
6
  */
6
7
  import { CommandRegistry } from './registry.js';
7
8
  export declare function registerBuiltinCommands(registry: CommandRegistry): void;
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Built-in slash commands (spec §5.2):
3
3
  * /help /model /settings /thinking /max-tokens /tools /clear /reset /cwd /save
4
- * /resume /copy /exit (/quit), plus the /team, /fast and /todo subsystems.
4
+ * /resume /copy /terminal-setup /exit (/quit), plus the /team, /fast and
5
+ * /todo subsystems.
5
6
  */
6
7
  import { existsSync, statSync } from 'node:fs';
7
8
  import { isAbsolute, resolve } from 'node:path';
@@ -1013,7 +1014,10 @@ const COMMANDS = [
1013
1014
  const captured = port.captured();
1014
1015
  const lines = [
1015
1016
  `Mouse capture: ${captured ? 'on' : 'off'}`,
1016
- ` drag-select ${port.selectEnabled() ? 'on' : 'off (mouseSelect is false)'}`,
1017
+ // The wording follows tui-shift-enter-copy-queue 4.4: releasing no
1018
+ // longer copies, so this row must name the commit key -- leaving the
1019
+ // bare `on` would keep teaching the gesture that was removed.
1020
+ ` drag-select ${port.selectEnabled() ? 'on (drag to select, ctrl+c to copy)' : 'off (mouseSelect is false)'}`,
1017
1021
  ' /mouse off hand the mouse to your terminal for this session',
1018
1022
  ' /mouse on take it back',
1019
1023
  ' --no-mouse never capture it (also ARAGON_MOUSE=0)',
@@ -1022,6 +1026,47 @@ const COMMANDS = [
1022
1026
  ctx.notify('info', lines.join('\n'));
1023
1027
  },
1024
1028
  },
1029
+ {
1030
+ name: 'terminal-setup',
1031
+ description: 'How to make Shift+Enter send a newline in your terminal',
1032
+ run: (ctx) => {
1033
+ // READ-ONLY BY DESIGN (3.6): pushing kitty keyboard mode or
1034
+ // modifyOtherKeys=2 would re-encode EVERY modified key as CSI-u and
1035
+ // blind Ink's parser for the whole session. One user-side binding
1036
+ // reaches the same result for the one key that needs it.
1037
+ //
1038
+ // Ctrl+J and Alt+Enter work with no setup at all; CSI-u terminals
1039
+ // (kitty / WezTerm / foot with keyboard enhancement on) need none
1040
+ // either. The lines below are for the terminals that send a plain
1041
+ // CR for Shift+Enter and CAN be configured.
1042
+ const lines = [
1043
+ 'Shift+Enter newline - terminal setup',
1044
+ '',
1045
+ 'Ctrl+J and Alt+Enter insert a newline in every terminal, with no',
1046
+ 'setup. To get a dedicated Shift+Enter key, make the terminal send',
1047
+ 'the CSI-u sequence \\u001b[13;2u for it:',
1048
+ '',
1049
+ ' Windows Terminal settings.json, actions array:',
1050
+ ' { "keys": "shift+enter", "command": { "action": "sendInput",',
1051
+ ' "input": "\\u001b[13;2u" } }',
1052
+ ' VS Code terminal keybindings.json:',
1053
+ ' { "key": "shift+enter",',
1054
+ ' "command": "workbench.action.terminal.sendSequence",',
1055
+ ' "args": { "text": "\\u001b[13;2u" } }',
1056
+ ' iTerm2 Settings > Profiles > Keys > Key Mappings:',
1057
+ ' Shift+Enter -> Send Escape Sequence: [13;2u',
1058
+ ' kitty/WezTerm/foot native once keyboard enhancement is on',
1059
+ ' (kitty: map shift+enter send_text all \\u001b[13;2u)',
1060
+ ' Alacritty alacritty.toml, [keyboard] bindings:',
1061
+ ' { key = "Enter", mods = "Shift", chars = "\\u001b[13;2u" }',
1062
+ ' macOS Terminal.app / conhost cannot rebind Enter; use Ctrl+J',
1063
+ ' or Alt+Enter',
1064
+ '',
1065
+ 'This command only prints advice; it changes no terminal modes.',
1066
+ ];
1067
+ ctx.notify('info', lines.join('\n'));
1068
+ },
1069
+ },
1025
1070
  {
1026
1071
  name: 'logs',
1027
1072
  description: 'Show where this session is being logged',