@aragon-agent/cli 0.6.7 → 0.6.9

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 (117) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +107 -77
  3. package/dist/agent/controller.d.ts +11 -7
  4. package/dist/agent/controller.js +35 -48
  5. package/dist/agent/headless.d.ts +2 -1
  6. package/dist/agent/prompt-options.d.ts +15 -0
  7. package/dist/agent/prompt-options.js +1 -0
  8. package/dist/agent/reducer.d.ts +7 -113
  9. package/dist/agent/reducer.js +0 -62
  10. package/dist/cli.js +111 -182
  11. package/dist/commands/builtins.js +3 -1
  12. package/dist/commands/perf.d.ts +32 -2
  13. package/dist/commands/perf.js +32 -2
  14. package/dist/commands/registry.d.ts +4 -5
  15. package/dist/config/env.js +0 -130
  16. package/dist/config/load.d.ts +0 -2
  17. package/dist/config/load.js +2 -31
  18. package/dist/config/schema.d.ts +1 -14
  19. package/dist/config/schema.js +0 -1
  20. package/dist/config/store.js +2 -0
  21. package/dist/input/interrupt-gesture.d.ts +18 -0
  22. package/dist/input/interrupt-gesture.js +19 -0
  23. package/dist/input/pointer-router.d.ts +11 -0
  24. package/dist/input/pointer-router.js +14 -0
  25. package/dist/logging/install.d.ts +2 -2
  26. package/dist/logging/install.js +2 -2
  27. package/dist/todo/follow-through.d.ts +0 -4
  28. package/dist/todo/follow-through.js +0 -4
  29. package/dist/todo/limits.d.ts +0 -10
  30. package/dist/todo/limits.js +0 -10
  31. package/dist/todo/prompt.d.ts +0 -15
  32. package/dist/todo/store.d.ts +8 -13
  33. package/dist/todo/store.js +13 -14
  34. package/dist/todo/types.d.ts +2 -1
  35. package/dist/ui/ActivityLine.d.ts +26 -34
  36. package/dist/ui/ActivityLine.js +30 -19
  37. package/dist/ui/App.d.ts +3 -41
  38. package/dist/ui/App.js +223 -247
  39. package/dist/ui/BottomStatusRow.d.ts +1 -3
  40. package/dist/ui/BottomStatusRow.js +4 -8
  41. package/dist/ui/Composer.d.ts +38 -5
  42. package/dist/ui/Composer.js +45 -23
  43. package/dist/ui/Header.d.ts +2 -16
  44. package/dist/ui/Header.js +3 -18
  45. package/dist/ui/Logo.d.ts +1 -17
  46. package/dist/ui/Logo.js +0 -16
  47. package/dist/ui/ModeChip.d.ts +7 -18
  48. package/dist/ui/ModeChip.js +9 -0
  49. package/dist/ui/PromptCaret.d.ts +11 -0
  50. package/dist/ui/PromptCaret.js +31 -0
  51. package/dist/ui/PromptInput.d.ts +10 -33
  52. package/dist/ui/PromptInput.js +37 -20
  53. package/dist/ui/StatusBar.d.ts +1 -11
  54. package/dist/ui/StatusBar.js +14 -8
  55. package/dist/ui/ToastStack.d.ts +2 -18
  56. package/dist/ui/ToastStack.js +4 -11
  57. package/dist/ui/Transcript.d.ts +5 -71
  58. package/dist/ui/Transcript.js +10 -199
  59. package/dist/ui/composer-rows.js +50 -9
  60. package/dist/ui/entries/AssistantEntry.d.ts +0 -23
  61. package/dist/ui/entries/AssistantEntry.js +3 -4
  62. package/dist/ui/entries/DiffView.d.ts +1 -7
  63. package/dist/ui/entries/DiffView.js +2 -2
  64. package/dist/ui/entries/ToolCard.d.ts +0 -6
  65. package/dist/ui/entries/ToolCard.js +4 -13
  66. package/dist/ui/frame-differ.d.ts +13 -0
  67. package/dist/ui/frame-differ.js +37 -24
  68. package/dist/ui/frame-observer.d.ts +17 -0
  69. package/dist/ui/frame-observer.js +81 -0
  70. package/dist/ui/frame-parser.d.ts +7 -0
  71. package/dist/ui/frame-parser.js +18 -0
  72. package/dist/ui/ink-runtime.d.ts +1 -0
  73. package/dist/ui/ink-runtime.js +21 -0
  74. package/dist/ui/layout/AppShell.d.ts +4 -58
  75. package/dist/ui/layout/AppShell.js +3 -6
  76. package/dist/ui/layout/OverlayFrame.d.ts +1 -25
  77. package/dist/ui/layout/OverlayFrame.js +0 -24
  78. package/dist/ui/layout/ScrollIndicator.d.ts +4 -3
  79. package/dist/ui/layout/ScrollIndicator.js +4 -4
  80. package/dist/ui/layout/ScrollViewport.d.ts +16 -66
  81. package/dist/ui/layout/ScrollViewport.js +195 -185
  82. package/dist/ui/layout/budget.d.ts +3 -51
  83. package/dist/ui/layout/budget.js +4 -53
  84. package/dist/ui/layout/document-layout.d.ts +13 -0
  85. package/dist/ui/layout/document-layout.js +11 -0
  86. package/dist/ui/layout/follow-state.d.ts +1 -0
  87. package/dist/ui/layout/follow-state.js +1 -3
  88. package/dist/ui/layout/frame.d.ts +0 -22
  89. package/dist/ui/layout/frame.js +0 -36
  90. package/dist/ui/layout/scroll-indicator.d.ts +1 -1
  91. package/dist/ui/layout/scroll-indicator.js +3 -2
  92. package/dist/ui/layout/scrollbar-geometry.d.ts +17 -0
  93. package/dist/ui/layout/scrollbar-geometry.js +13 -0
  94. package/dist/ui/layout/todo-layout.d.ts +2 -3
  95. package/dist/ui/layout/todo-layout.js +9 -9
  96. package/dist/ui/layout/viewport-geometry.d.ts +2 -1
  97. package/dist/ui/layout/viewport-geometry.js +2 -1
  98. package/dist/ui/layout/virtual-window.d.ts +1 -0
  99. package/dist/ui/layout/virtual-window.js +1 -1
  100. package/dist/ui/live-clamp.d.ts +1 -29
  101. package/dist/ui/live-clamp.js +1 -29
  102. package/dist/ui/overlays/HelpOverlay.js +6 -2
  103. package/dist/ui/run-status-row.d.ts +61 -0
  104. package/dist/ui/run-status-row.js +70 -0
  105. package/dist/ui/screen.d.ts +1 -1
  106. package/dist/ui/screen.js +4 -6
  107. package/dist/ui/scrollbar-controller.d.ts +30 -0
  108. package/dist/ui/scrollbar-controller.js +124 -0
  109. package/dist/ui/transcript-text.d.ts +0 -8
  110. package/dist/ui/transcript-text.js +0 -8
  111. package/dist/ui/use-startup-notices.d.ts +1 -57
  112. package/dist/ui/use-startup-notices.js +3 -54
  113. package/dist/ui/use-wheel-routing.d.ts +3 -1
  114. package/dist/ui/use-wheel-routing.js +6 -0
  115. package/package.json +2 -2
  116. package/dist/ui/TodoStrip.d.ts +0 -36
  117. package/dist/ui/TodoStrip.js +0 -32
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,11 +41,13 @@ 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';
47
47
  import { tryCreateStdinFilter } from './input/stdin-filter.js';
48
+ import { createScrollbarBridge } from './ui/scrollbar-controller.js';
49
+ import { createPointerRouter } from './input/pointer-router.js';
50
+ import { createFrameObserver } from './ui/frame-observer.js';
48
51
  import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
49
52
  import { supportsWindowsVtInput } from './ui/win-vt-input.js';
50
53
  import { forceWindowsVtInput } from './ui/win-vt-force.js';
@@ -93,7 +96,6 @@ function toFlags(opts) {
93
96
  confirm: opts.confirm,
94
97
  toolTimeout: opts.toolTimeout,
95
98
  idleTimeout: opts.idleTimeout,
96
- fullscreen: opts.fullscreen,
97
99
  exitTranscript: opts.exitTranscript,
98
100
  compact: opts.compact,
99
101
  hints: opts.hints,
@@ -227,14 +229,8 @@ function makeController(flags, opts = { interactive: true }) {
227
229
  // resolved a bootstrap level — it ran before commander and before `.env` were
228
230
  // readable — and without this hand-off the fully resolved one never applies.
229
231
  getLogger().reconfigure(config.log);
230
- // Whether this session could EVER show the todo rail (todo-plan-execution
231
- // §3.7 / P1-6). It varies ONE sentence of `<todo_planning>`; `decideRenderMode`
232
- // is pure and `runInteractive` calls it again with the same inputs, so
233
- // computing it here rather than threading it back costs nothing and keeps the
234
- // controller's construction self-contained.
235
- const todoPanelCapable = opts.interactive &&
236
- decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout) ===
237
- 'fullscreen';
232
+ // Headless controllers never expose the task panel.
233
+ const todoPanelCapable = opts.interactive;
238
234
  const confirmBridge = { handler: null };
239
235
  // `cancelPending` is replaced by the App's own implementation on mount; this
240
236
  // no-op keeps the object total so nothing has to null-check it.
@@ -386,11 +382,15 @@ function forceVtInputForThisConsole() {
386
382
  * renders Ink, `runOneShot()` goes to `runHeadless()`, which never does.
387
383
  */
388
384
  function runInteractive(flags, extras = {}) {
385
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
386
+ process.stderr.write('Interactive UI requires a TTY. Use -p "<prompt>", exec, or config list / config set.\n');
387
+ process.exitCode = 2;
388
+ return;
389
+ }
389
390
  const { controller, confirmBridge, humanInputBridge } = makeController(flags);
390
391
  const config = controller.getConfig();
391
- const mode = decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout);
392
392
  const logger = getLogger();
393
- logger.info('cli', 'run_interactive', { mode, provider: config.provider, model: config.model });
393
+ logger.info('cli', 'run_interactive', { mode: 'fullscreen', provider: config.provider, model: config.model });
394
394
  attachAgentEvents(logger, controller);
395
395
  // The team stream is CLI-local (D-10), so `attachAgentEvents` cannot see it.
396
396
  // Without this a dispatch leaves NO trace in the log file at all — the wrong
@@ -439,8 +439,7 @@ function runInteractive(flags, extras = {}) {
439
439
  // a console this process just repaired.
440
440
  const vtInputByNode = supportsWindowsVtInput(process.platform, process.versions.node);
441
441
  const vtInputSupported = vtInputByNode || forceVtInputForThisConsole();
442
- const wantMouse = mode === 'fullscreen' &&
443
- config.mouse &&
442
+ const wantMouse = config.mouse &&
444
443
  !!process.stdout.isTTY &&
445
444
  !!process.stdin.isTTY &&
446
445
  vtInputSupported;
@@ -476,33 +475,26 @@ function runInteractive(flags, extras = {}) {
476
475
  const disposeStdinFilter = () => filter?.dispose();
477
476
  // --- Drag-select (tui-selection-and-scroll-follow §4.4). ------------------
478
477
  //
479
- // ONE EXPRESSION, READ BY FOUR THINGS, and that is the same discipline
480
- // `wantMouse` above follows for the same reason: the `?1002h` write, the
481
- // controller, the differ's `decorate` hook and the startup notice must never
482
- // disagree about whether drag-select is running. With it false the session is
483
- // byte-identical to a pre-feature build — no extra escape on the wire (AC-8),
484
- // no controller, no `decorate`, no mirror.
478
+ // Selection owns its controller, decoration and notice. Button motion follows
479
+ // mouseOn independently so --no-mouse-select still permits scrollbar dragging.
485
480
  const wantSelect = mouseOn && config.mouseSelect;
481
+ let mouseCaptured = mouseOn;
482
+ const scrollbar = createScrollbarBridge(() => mouseCaptured);
483
+ const pointerRouter = mouseOn && filter ? createPointerRouter({
484
+ source: filter.source, handle: (event) => scrollbar.controller?.handle(event) ?? false,
485
+ }) : null;
486
486
  const selectionBridge = {
487
487
  controller: null,
488
488
  onCopied: null,
489
489
  requestRedraw: null,
490
490
  };
491
- // --- Frame differ (tui-input-flicker-fix §3.2 / §4.2). -------------------
492
- //
493
- // FULL-SCREEN ONLY, and that is invariant I-3 rather than caution: inline mode
494
- // prints settled entries through Ink's `<Static>`, starts wherever the shell's
495
- // cursor happened to be and scrolls, so row k of a frame is not absolute row k
496
- // there and never will be. `--no-diff-render` skips construction entirely,
497
- // which is what makes rung 2 of the fail-safe ladder byte-identical to a
498
- // pre-fix build rather than merely equivalent.
499
- //
500
- // `rows` is a THUNK, not a snapshot: it is the only input to the I-9 geometry
501
- // guard and it changes under the differ's feet on every resize.
502
- const frameWriter = mode === 'fullscreen' && config.diffRender
491
+ const frameWriter = config.diffRender
503
492
  ? wrapStdoutForFrames(process.stdout, createFrameDiffer({
504
493
  sync: config.syncOutput && process.env.TERM !== 'dumb',
505
494
  rows: () => process.stdout.rows,
495
+ // Rows that fill the line skip `CSI K` so the scrollbar's last column
496
+ // is not erased on pending-wrap terminals (frame-differ I-4).
497
+ cols: () => process.stdout.columns,
506
498
  // The console bridge (`App.tsx`, full-screen only) turns this into
507
499
  // `dispatch({type:'notice', level:'warn'})`, which is the only legal
508
500
  // user-visible channel with the TUI mounted — a direct write would
@@ -535,14 +527,20 @@ function runInteractive(flags, extras = {}) {
535
527
  // than printing zeroes that read like a writer doing nothing (§5.4).
536
528
  if (frameWriter)
537
529
  setFrameStatsProvider(frameWriter.stats);
530
+ const frameObserver = createFrameObserver({
531
+ stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
532
+ });
538
533
  const disposeFrameWriter = () => {
534
+ frameObserver.dispose();
535
+ pointerRouter?.dispose();
536
+ scrollbar.controller?.dispose();
539
537
  if (!frameWriter)
540
538
  return;
541
539
  setFrameStatsProvider(null);
542
540
  frameWriter.dispose();
543
541
  };
544
542
  const replayTranscript = () => {
545
- if (replayed || mode !== 'fullscreen' || !config.exitTranscript)
543
+ if (replayed || !config.exitTranscript)
546
544
  return;
547
545
  replayed = true;
548
546
  const snapshot = readExitSnapshot();
@@ -561,7 +559,7 @@ function runInteractive(flags, extras = {}) {
561
559
  ...(snapshot.droppedEntries ? { droppedEntries: snapshot.droppedEntries } : {}),
562
560
  }));
563
561
  };
564
- if (mode === 'fullscreen') {
562
+ {
565
563
  // `mouse` here means "a mouse-parsing filter is installed", NOT "the user
566
564
  // wants mouse support" — see `AltScreenOptions`. `screen.ts` must never
567
565
  // derive it for itself, or a later refactor re-opens the gap I-8 closes.
@@ -574,8 +572,8 @@ function runInteractive(flags, extras = {}) {
574
572
  // keep their terminal's own selection.
575
573
  screen = enterAltScreen(process.stdout, {
576
574
  mouse: mouseOn,
577
- motion: wantSelect,
578
- bracketedPaste: pasteOn && mode === 'fullscreen',
575
+ motion: mouseOn,
576
+ bracketedPaste: pasteOn,
579
577
  });
580
578
  const restore = () => screen?.restore();
581
579
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
@@ -600,44 +598,8 @@ function runInteractive(flags, extras = {}) {
600
598
  process.exit(128 + signo);
601
599
  });
602
600
  }
603
- // --- Auto-update (cli-auto-update section 3.8). --------------------------
604
- //
605
- // THE ONLY CONSTRUCTION SITE. `runOneShot` / `runHeadless` never reach this
606
- // function, so `aragon -p` executes zero update code and its stdout is
607
- // byte-identical to a pre-feature build (AC-1); `aragon config <subcommand>`,
608
- // `aragon logs`, `aragon skills` and `aragon --version` likewise. Bare
609
- // `aragon config` DOES construct it, because it is a full interactive session
610
- // with a settings overlay on top - which is correct, and is why AC-1's claim
611
- // names the SUBCOMMAND form.
612
- //
613
- // `CI` is excluded on top of the two TTY tests because a CI runner with a pty
614
- // allocated is otherwise a machine that silently mutates its own global
615
- // toolchain mid-pipeline (D-11).
616
- // --- The foreign-write door (§4.4.5). ------------------------------------
617
- //
618
- // Where bytes that are OURS but are NOT a frame go — today, the OSC 52
619
- // clipboard sequence. THREE SHAPES, ONE DOOR, and the third one is the reason
620
- // this is a resolved value rather than an inline arrow:
621
- //
622
- // * with the differ in front of stdout it MUST be `frameWriter.writeForeign`,
623
- // which invalidates the cache and skips the fallback accounting — through
624
- // the proxy the same bytes are an unrecognised chunk, which is
625
- // `passThrough(true)`, which prints `FRAME_FALLBACK_NOTICE` at a user who
626
- // did nothing but copy something (P1-6);
627
- // * without one — `--no-diff-render`, and inline mode, which never builds a
628
- // differ at all — stdout IS the real stream, so a direct write is both safe
629
- // and what §4.4.5 prescribes. OSC 52 emits no newline, so Ink's own line
630
- // accounting is untouched and there is nothing to invalidate;
631
- // * on a non-TTY there is no terminal to accept it, so the door is ABSENT.
632
- //
633
- // It must never be a function that accepts a chunk and writes NOWHERE.
634
- // `copyText` reports `'osc52'` whenever its door took the text, so a silent
635
- // no-op door — which `frameWriter?.writeForeign(chunk)` is under
636
- // `--no-diff-render` — makes the toast name a mechanism that was never
637
- // attempted. Naming the mechanism honestly is the entire reason that return
638
- // value exists (R-7), and a door that lies is worse than no door.
639
601
  const writeForeign = frameWriter
640
- ? (text) => frameWriter.writeForeign(text)
602
+ ? (text) => { frameObserver?.invalidate(); frameWriter.writeForeign(text); }
641
603
  : process.stdout.isTTY
642
604
  ? (text) => {
643
605
  try {
@@ -659,10 +621,9 @@ function runInteractive(flags, extras = {}) {
659
621
  // than inside `screen.ts`'s closure because `App` needs to READ it and the
660
622
  // handle's own flag is private (it has to be — `restore()` reads it, and
661
623
  // exposing a setter would be a second way to get the two out of step).
662
- let mouseCaptured = mouseOn;
663
624
  if (wantSelect && filter) {
664
625
  selectionBridge.controller = createSelectionController({
665
- source: filter.source,
626
+ source: pointerRouter?.selectionSource ?? filter.source,
666
627
  // The frame writer is absent under `--no-diff-render`, where there is no
667
628
  // cache to repaint from at all; the controller then falls back to asking
668
629
  // `App` for a React redraw on every drag flush, which is slower and
@@ -686,79 +647,82 @@ function runInteractive(flags, extras = {}) {
686
647
  selectionBridge.controller?.dispose();
687
648
  selectionBridge.controller = null;
688
649
  };
689
- /**
690
- * The terminal-facing services `App` cannot build for itself (§4.4).
691
- *
692
- * PRESENT IN INLINE MODE TOO, and only for the foreign-write door: §4.4.5
693
- * routes `/copy` through `ui/clipboard.ts` precisely so it gains OSC 52 — the
694
- * one mechanism that reaches the clipboard of the machine the USER is sitting
695
- * at over SSH — and gating the whole bridge on full-screen would have handed
696
- * that back only to full-screen users while the command is available in both.
697
- * Every OTHER field stays absent there, so the inline tree is unchanged: there
698
- * is no filter, hence no `setMouseCapture` and no `ctx.mouse`; `mouseSelect`
699
- * is `wantSelect`, which is already false without one; and there is no
700
- * controller, so no `selection`.
701
- */
702
- const terminalBridge = mode === 'fullscreen' || writeForeign
703
- ? {
704
- mouseSelect: wantSelect,
705
- ...(mouseOn
706
- ? {
707
- setMouseCapture: (on) => {
708
- screen?.setMouseCapture(on);
709
- mouseCaptured = on;
710
- // A released mouse cannot be dragging, and a selection left
711
- // painted over a screen the terminal is now selecting on its
712
- // own would be two highlights claiming the same rows.
713
- if (!on)
714
- selectionBridge.controller?.clear();
715
- },
716
- isMouseCaptured: () => mouseCaptured,
717
- }
718
- : {}),
719
- // The same door the controller copies through, so `/copy` and a
720
- // drag-release can never disagree about which stream OSC 52 goes to.
721
- ...(writeForeign ? { writeForeign } : {}),
722
- ...(selectionBridge.controller ? { selection: selectionBridge } : {}),
723
- }
724
- : undefined;
650
+ const terminalBridge = {
651
+ mouseSelect: wantSelect,
652
+ scrollbar,
653
+ ...(mouseOn
654
+ ? {
655
+ setMouseCapture: (on) => {
656
+ screen?.setMouseCapture(on);
657
+ mouseCaptured = on;
658
+ if (!on)
659
+ scrollbar.controller?.cancel();
660
+ // A released mouse cannot be dragging, and a selection left
661
+ // painted over a screen the terminal is now selecting on its
662
+ // own would be two highlights claiming the same rows.
663
+ if (!on)
664
+ selectionBridge.controller?.clear();
665
+ },
666
+ isMouseCaptured: () => mouseCaptured,
667
+ }
668
+ : {}),
669
+ // The same door the controller copies through, so `/copy` and a
670
+ // drag-release can never disagree about which stream OSC 52 goes to.
671
+ ...(writeForeign ? { writeForeign } : {}),
672
+ ...(selectionBridge.controller ? { selection: selectionBridge } : {}),
673
+ };
725
674
  const updateBridge = { service: null, onAttach: null };
726
675
  let updateService = null;
676
+ let interactiveClosed = false;
677
+ const failInteractive = (error) => {
678
+ // Ink consumes render exceptions; retain failure even if a disposer throws.
679
+ process.exitCode = 1;
680
+ interactiveClosed = true;
681
+ logger.error('cli', 'render_failed', { error: String(error) });
682
+ for (const cleanup of [
683
+ () => screen?.restore(), disposeSelection, disposeStdinFilter, disposeFrameWriter,
684
+ () => controller.abort(), () => controller.dispose(), () => updateService?.dispose(),
685
+ () => { process.stdin.pause(); process.stdin.unref?.(); },
686
+ ]) {
687
+ try {
688
+ cleanup();
689
+ }
690
+ catch (cleanupError) {
691
+ logger.error('cli', 'render_cleanup_failed', { error: String(cleanupError) });
692
+ }
693
+ }
694
+ };
727
695
  const updateEligible = !!process.stdout.isTTY &&
728
696
  !!process.stdin.isTTY &&
729
697
  !process.env.CI &&
730
698
  config.update.mode !== 'off';
731
- const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay,
732
- // `mouseOn`, NOT the handle (I-11). `App.tsx:186-188` documents this
733
- // prop's contract as "absent under `--no-mouse`, inline, or non-TTY", and
734
- // it feeds `enabled: !!mouseSource` on a wheel subscription that would
735
- // otherwise be a channel no event can ever arrive on.
736
- mouseSource: mouseOn && filter ? filter.source : undefined,
737
- // Present ONLY when this console swallows `CSI Z` and mouse reports.
738
- // Passed in rather than probed inside `App` so that every test renders
739
- // the same tree on every machine — reading `process.versions.node` from
740
- // a component would make the notice appear or vanish according to which
741
- // Node the developer happened to have installed.
742
- vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
743
- // Absent when nothing can refuse a paste, so a `--no-paste` tree is
744
- // byte-identical to a build without this feature.
745
- pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge }),
746
- // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
747
- // straight to stdout and permanently shifts the fixed frame's accounting.
748
- {
749
- exitOnCtrlC: false,
750
- patchConsole: mode === 'inline',
751
- // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
752
- // every existing path is provably unchanged.
753
- stdin: filter?.stdin ?? process.stdin,
754
- // SPREAD, not `frameWriter?.stdout ?? process.stdout`. This call passed no
755
- // `stdout` at all before, and Ink defaults it to `process.stdout`; omitting
756
- // the key entirely when the writer is off keeps the options object the same
757
- // SHAPE it has always had, which is what makes AC-5 / AC-6 ("byte-identical
758
- // with --no-diff-render, inline mode and `aragon -p`") a statement about
759
- // the code rather than about Ink's defaulting.
760
- ...(frameWriter ? { stdout: frameWriter.stdout } : {}),
761
- });
699
+ let instance;
700
+ try {
701
+ instance = render(_jsx(App, { controller: controller, version: VERSION, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseOn && filter ? filter.source : undefined,
702
+ // Present ONLY when this console swallows `CSI Z` and mouse reports.
703
+ // Passed in rather than probed inside `App` so that every test renders
704
+ // the same tree on every machine — reading `process.versions.node` from
705
+ // a component would make the notice appear or vanish according to which
706
+ // Node the developer happened to have installed.
707
+ vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
708
+ // Absent when nothing can refuse a paste, so a `--no-paste` tree is
709
+ // byte-identical to a build without this feature.
710
+ pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge }),
711
+ // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
712
+ // straight to stdout and permanently shifts the fixed frame's accounting.
713
+ {
714
+ exitOnCtrlC: false,
715
+ patchConsole: false,
716
+ // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
717
+ // every existing path is provably unchanged.
718
+ stdin: filter?.stdin ?? process.stdin,
719
+ stdout: frameObserver.stdout,
720
+ });
721
+ }
722
+ catch (error) {
723
+ failInteractive(error);
724
+ throw error;
725
+ }
762
726
  // H1's primary disarm, and this is the earliest moment at which "this build
763
727
  // STARTS" is proven: the module graph loaded, config resolved, Ink mounted
764
728
  // (cli-auto-update-hardening section 5.1.4). It is what makes a long session
@@ -780,6 +744,8 @@ function runInteractive(flags, extras = {}) {
780
744
  // must degrade to "no updater", never to a crashed TUI.
781
745
  void import('./update/service.js')
782
746
  .then(({ UpdateService }) => {
747
+ if (interactiveClosed)
748
+ return;
783
749
  const service = new UpdateService({ config: config.update, currentVersion: VERSION });
784
750
  updateService = service;
785
751
  updateBridge.service = service;
@@ -810,6 +776,7 @@ function runInteractive(flags, extras = {}) {
810
776
  void instance
811
777
  .waitUntilExit()
812
778
  .then(() => {
779
+ interactiveClosed = true;
813
780
  screen?.restore();
814
781
  // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
815
782
  // and after `restore()` for the reason that call records: the terminal must
@@ -838,18 +805,7 @@ function runInteractive(flags, extras = {}) {
838
805
  updateService?.dispose();
839
806
  replayTranscript();
840
807
  })
841
- .catch(() => {
842
- screen?.restore();
843
- // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
844
- // and after `restore()` for the reason that call records: the terminal must
845
- // stop reporting first, and anything already in flight is still consumed by
846
- // a live filter.
847
- disposeSelection();
848
- disposeStdinFilter();
849
- disposeFrameWriter();
850
- controller.dispose();
851
- updateService?.dispose();
852
- });
808
+ .catch(failInteractive);
853
809
  }
854
810
  async function runOneShot(flags, prompt, quiet) {
855
811
  // Headless: no App is ever rendered, so there is no human to approve a skill
@@ -938,7 +894,6 @@ const CONFIG_SET_KEYS = new Set([
938
894
  'confirmTools',
939
895
  'toolTimeoutMs',
940
896
  'idleTimeoutMs',
941
- 'fullscreen',
942
897
  'exitTranscript',
943
898
  'transcriptWindow',
944
899
  // tui-render-performance L1 / L4. Listed here AND cased in the switch
@@ -1159,9 +1114,6 @@ function runConfigSet(key, value) {
1159
1114
  case 'idleTimeoutMs':
1160
1115
  patch.idleTimeoutMs = coercePositiveInt(value, 210_000);
1161
1116
  break;
1162
- case 'fullscreen':
1163
- patch.fullscreen = value === 'true' || value === '1';
1164
- break;
1165
1117
  case 'exitTranscript':
1166
1118
  patch.exitTranscript = value === 'true' || value === '1';
1167
1119
  break;
@@ -1450,30 +1402,16 @@ function buildProgram() {
1450
1402
  .option('--tool-timeout <ms>', 'Per-tool executor ceiling (default 180000)')
1451
1403
  .option('--idle-timeout <ms>', 'Watchdog idle timeout (auto-raised to >= tool-timeout+30s)')
1452
1404
  .option('--theme <name>', 'auto|warm|cool|light ("dark" is an alias for "cool")')
1453
- // Same tri-state shape as `--fullscreen`: declaring the positive form first
1454
- // keeps the default `undefined`, so "no opinion" stays distinguishable from
1455
- // an explicit choice and the config layer below can still win.
1456
1405
  .option('--compact', 'Compact transcript density (no blank rows between turns)')
1457
1406
  .option('--no-compact', 'Comfortable transcript density')
1458
1407
  .option('--hints', 'Always show the composer hint row')
1459
1408
  .option('--no-hints', 'Hide the composer hint row')
1460
- // Positive form first, same tri-state reason as `--fullscreen` above.
1461
1409
  .option('--show-thinking', 'Show the reasoning blocks the model returns')
1462
1410
  .option('--no-show-thinking', 'Hide reasoning blocks (the default)')
1463
- // Positive form first, same tri-state reason as `--fullscreen` above. The
1464
- // pair matters more here than for its neighbour because this key defaults to
1465
- // ON: a lone negative form would let commander synthesize `true` and
1466
- // overwrite a stored `false` on every run that passed no flag at all.
1467
1411
  .option('--live-tool-output', 'Show a running command output tail (the default)')
1468
1412
  .option('--no-live-tool-output', 'Keep a running tool card to one line')
1469
- // Positive form first, same tri-state reason as `--fullscreen` above.
1470
1413
  .option('--mouse', 'Wheel scrolls the transcript (full-screen mode)')
1471
1414
  .option('--no-mouse', 'Leave the mouse to the terminal (the wheel does nothing)')
1472
- // Positive form FIRST, for the tri-state reason `--fullscreen` records
1473
- // below: `mouseSelect` is persisted and defaults to `true`, so a lone
1474
- // `--no-mouse-select` would make commander default `opts.mouseSelect` to
1475
- // `true` — indistinguishable from silence, and silently overriding a stored
1476
- // `false` on every run that passes no flag at all.
1477
1415
  .option('--mouse-select', 'Drag with the mouse to select text; releasing copies it')
1478
1416
  .option('--no-mouse-select', 'Keep wheel scrolling, but leave drag-select off')
1479
1417
  // Positive form FIRST, same tri-state reason as `--mouse-select` above:
@@ -1482,16 +1420,7 @@ function buildProgram() {
1482
1420
  // silence, and silently overriding a stored `false` on every run.
1483
1421
  .option('--paste', 'Collapse large pastes into a placeholder (the default)')
1484
1422
  .option('--no-paste', 'Treat pasted bytes as keystrokes (pre-0.6.3 behavior)')
1485
- // Declaring `--fullscreen` BEFORE `--no-fullscreen` keeps the default
1486
- // `undefined` instead of `true`, which is what makes the tri-state work:
1487
- // "no opinion" must stay distinguishable from "force it".
1488
- .option('--fullscreen', 'Force the full-screen TUI (overrides auto-downgrade, except non-TTY)')
1489
- .option('--no-fullscreen', 'Force the inline renderer (v0.2.0 behavior)')
1490
1423
  .option('--no-exit-transcript', 'Do not replay the session summary after exiting')
1491
- // Same tri-state shape as `--fullscreen`: the positive form is declared
1492
- // FIRST so the default stays `undefined` and "no opinion" remains
1493
- // distinguishable from an explicit `--no-plan`, which is what lets the
1494
- // config layer win when neither flag was given.
1495
1424
  .option('--plan', 'Start the session in PLAN mode (read-only research + review)')
1496
1425
  .option('--no-plan', 'Start the session in BUILD mode (overrides planModeDefault)')
1497
1426
  // BOTH FORMS, positive first, and the pair is mandatory (team-subagents
@@ -724,7 +724,9 @@ const COMMANDS = [
724
724
  // slash history already has. Putting this canned paragraph under the
725
725
  // composer's up-arrow as well would be the same event recorded twice, in
726
726
  // the more verbose of the two forms (AC-17).
727
- ctx.submit(buildContinuationMessage(snapshot), { userInitiated: false });
727
+ ctx.submit(buildContinuationMessage(snapshot), {
728
+ userInitiated: false, todoPolicy: 'continue',
729
+ });
728
730
  return;
729
731
  }
730
732
  ctx.notify('warn', `Unknown argument "${verb}" - use ` +
@@ -16,6 +16,7 @@
16
16
  * display would be self-defeating.
17
17
  */
18
18
  import type { FrameWriterStats } from '../ui/frame-differ.js';
19
+ import type { ScrollbarBridge } from '../ui/scrollbar-controller.js';
19
20
  import type { SlashCommand } from './registry.js';
20
21
  export interface PerfSnapshot {
21
22
  /** Governor rung, its resolved interval, and the last measured commit cost. */
@@ -33,10 +34,39 @@ export interface PerfSnapshot {
33
34
  heightsEstimated: number;
34
35
  cols: number;
35
36
  /** Geometry. */
36
- mode: 'fullscreen' | 'inline';
37
+ mode: 'fullscreen';
37
38
  viewportRows: number;
38
39
  offset: number;
40
+ /** Right-edge scrollbar diagnostics; read lazily so it reflects the live bridge. */
41
+ scrollbar?: ScrollbarPerf;
39
42
  }
43
+ /**
44
+ * Why the right-edge scrollbar is or is not usable, in one record
45
+ * (tui-scrollbar-edge-and-run-row section 3.2). "Cannot see it" and "cannot drag
46
+ * it" had no self-check before; each field below separates one cause.
47
+ */
48
+ export interface ScrollbarPerf {
49
+ /** 1-based terminal column of the track, or `null` before the first layout. */
50
+ trackCol: number | null;
51
+ trackRows: number | null;
52
+ /** Mouse capture is on. Off keeps the track visible but not draggable. */
53
+ mouseOn: boolean;
54
+ /** A full frame matching the geometry was observed, so pointer hits are trusted. */
55
+ frameReady: boolean;
56
+ thumb: {
57
+ start: number;
58
+ size: number;
59
+ } | null;
60
+ unicode: boolean;
61
+ /** `skipped`: the differ omits CSI K on full-width rows; `n/a`: diff render is off. */
62
+ edgeEl: 'skipped' | 'always' | 'n/a';
63
+ }
64
+ /** Build the diagnostics record from the live bridge (called at `/perf` time). */
65
+ export declare function readScrollbarPerf(opts: {
66
+ bridge: ScrollbarBridge | undefined;
67
+ diffRender: boolean;
68
+ unicode: boolean;
69
+ }): ScrollbarPerf | undefined;
40
70
  /** Called by `App`. Passing `null` detaches. */
41
71
  export declare function setPerfSnapshotProvider(fn: (() => PerfSnapshot | null) | null): void;
42
72
  export declare function readPerfSnapshot(): PerfSnapshot | null;
@@ -44,6 +74,6 @@ export declare function setPerfResetHook(fn: (() => void) | null): void;
44
74
  /** Called by `cli.tsx` after building the writer. Passing `null` detaches. */
45
75
  export declare function setFrameStatsProvider(fn: (() => FrameWriterStats) | null): void;
46
76
  export declare function readFrameStats(): FrameWriterStats | null;
47
- /** Format a snapshot as the six-line block documented in §5.4. */
77
+ /** Format a snapshot as the six-line block documented in §5.4 (plus the scrollbar line). */
48
78
  export declare function formatPerfReport(snap: PerfSnapshot): string;
49
79
  export declare const perfCommand: SlashCommand;
@@ -16,6 +16,23 @@
16
16
  * display would be self-defeating.
17
17
  */
18
18
  import { clearRenderCaches, renderCacheStats } from '../ui/render-cache.js';
19
+ /** Build the diagnostics record from the live bridge (called at `/perf` time). */
20
+ export function readScrollbarPerf(opts) {
21
+ const { bridge, diffRender, unicode } = opts;
22
+ if (!bridge)
23
+ return undefined;
24
+ const geometry = bridge.geometry;
25
+ return {
26
+ trackCol: geometry?.trackCol ?? null,
27
+ trackRows: geometry?.trackRows ?? null,
28
+ mouseOn: bridge.isEnabled(),
29
+ frameReady: bridge.frameReady,
30
+ thumb: geometry?.thumb ? { start: geometry.thumb.start, size: geometry.thumb.size } : null,
31
+ unicode,
32
+ // `cli.tsx` always wires `cols` when it builds the differ.
33
+ edgeEl: diffRender ? 'skipped' : 'n/a',
34
+ };
35
+ }
19
36
  /** Cleared on unmount, so `/perf` in a torn-down tree says so instead of lying. */
20
37
  let provider = null;
21
38
  /** Called by `App`. Passing `null` detaches. */
@@ -39,7 +56,7 @@ export function setPerfResetHook(fn) {
39
56
  * render — so routing them through a 1900-line component's props would buy
40
57
  * nothing and cost `App.tsx` plus `app.test.tsx` churn (P1-2).
41
58
  *
42
- * `null` means the writer was never built — `--no-diff-render`, inline mode, or
59
+ * `null` means the writer was never built — `--no-diff-render` or
43
60
  * a non-interactive path. `/perf` says so in words, so "the flag is off" and
44
61
  * "the writer crashed" never look alike.
45
62
  */
@@ -74,11 +91,23 @@ function formatWriterLine() {
74
91
  `lines/frame ${ratio(stats.linesWritten, stats.framesTotal)} - ` +
75
92
  `bytes/frame ${bytesPerFrame} - fallbacks ${stats.fallbacks}`);
76
93
  }
77
- /** Format a snapshot as the six-line block documented in §5.4. */
94
+ function formatScrollbarLine(bar) {
95
+ const thumb = bar.thumb ? `${bar.thumb.start}+${bar.thumb.size}` : 'none';
96
+ const line = `scrollbar col=${bar.trackCol ?? '-'} rows=${bar.trackRows ?? '-'} ` +
97
+ `mouse=${bar.mouseOn ? 'on' : 'off'} frameReady=${bar.frameReady ? 'yes' : 'no'} ` +
98
+ `thumb=${thumb} glyphs=${bar.unicode ? 'unicode' : 'ascii'} edge-el=${bar.edgeEl}`;
99
+ if (!bar.mouseOn || bar.frameReady)
100
+ return line;
101
+ const size = bar.trackCol !== null && bar.trackRows !== null
102
+ ? `${bar.trackCol}x${bar.trackRows + 4}` : 'the terminal size';
103
+ return `${line}\n waiting for a full frame matching ${size}`;
104
+ }
105
+ /** Format a snapshot as the six-line block documented in §5.4 (plus the scrollbar line). */
78
106
  export function formatPerfReport(snap) {
79
107
  const caches = renderCacheStats();
80
108
  const eco = snap.rung > 0 ? ' - eco' : '';
81
109
  const governor = snap.governorEnabled ? '' : ' - governor off';
110
+ const scrollbar = snap.scrollbar ? [formatScrollbarLine(snap.scrollbar)] : [];
82
111
  return [
83
112
  `render rung ${snap.rung} - interval ${snap.intervalMs}ms - last commit ` +
84
113
  `${Math.round(snap.lastCommitMs)}ms${eco}${governor}`,
@@ -91,6 +120,7 @@ export function formatPerfReport(snap) {
91
120
  `lines ${caches.lineEntries}`,
92
121
  `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
93
122
  formatWriterLine(),
123
+ ...scrollbar,
94
124
  ].join('\n');
95
125
  }
96
126
  export const perfCommand = {
@@ -2,6 +2,7 @@
2
2
  * Slash-command registry + dispatcher (spec §5.2). A leading `/` in the prompt
3
3
  * input is routed here instead of to the agent; `//` is the literal-`/` escape.
4
4
  */
5
+ import type { SubmitMessageOptions } from '../agent/prompt-options.js';
5
6
  import type { AgentController } from '../agent/controller.js';
6
7
  import type { AgentMode } from '../agent/agent-mode.js';
7
8
  import type { NoticeLevel, Overlay, ToastLevel, ViewAction, ViewState } from '../agent/reducer.js';
@@ -38,9 +39,7 @@ export interface CommandContext {
38
39
  * (P1-5): `builtins.ts` reaches `submitMessage` only through here, so
39
40
  * `/todo continue` cannot opt out of prompt history without it.
40
41
  */
41
- submit: (text: string, opts?: {
42
- userInitiated?: boolean;
43
- }) => void;
42
+ submit: (text: string, opts?: SubmitMessageOptions) => void;
44
43
  /**
45
44
  * The live auto-continue budget, for `/todo status`'s `(2/25 used)` clause
46
45
  * (todo-plan-followthrough §4.3).
@@ -90,7 +89,7 @@ export interface CommandContext {
90
89
  /**
91
90
  * Mouse capture, for `/mouse` (tui-selection-and-scroll-follow §6.2 / G3).
92
91
  *
93
- * OPTIONAL, and legitimately absent: inline mode, `--no-mouse`, a non-TTY, a
92
+ * OPTIONAL, and legitimately absent: `--no-mouse`, a non-TTY, a
94
93
  * Windows console that cannot deliver reports, and every test that builds this
95
94
  * context by literal. `/mouse` has to have something honest to say when it is
96
95
  * missing, which is the same reason `update` above it is optional.
@@ -102,7 +101,7 @@ export interface CommandContext {
102
101
  * NOT `process.stdout.write`. In full-screen mode stdout is a `Proxy` in front
103
102
  * of the frame differ, and an unrecognised chunk there is counted as a foreign
104
103
  * write and prints a diagnostic at the user the first time they copy anything
105
- * (P1-6). Absent in inline mode and with `--no-diff-render`, where the real
104
+ * (P1-6). Absent with `--no-diff-render`, where the real
106
105
  * stream is handed over directly.
107
106
  */
108
107
  writeForeign?: (text: string) => void;