@aragon-agent/cli 0.6.8 → 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 (78) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +38 -37
  3. package/dist/agent/controller.d.ts +1 -1
  4. package/dist/agent/reducer.d.ts +7 -113
  5. package/dist/agent/reducer.js +0 -62
  6. package/dist/cli.js +48 -149
  7. package/dist/commands/perf.d.ts +32 -2
  8. package/dist/commands/perf.js +32 -2
  9. package/dist/commands/registry.d.ts +2 -2
  10. package/dist/config/env.js +0 -130
  11. package/dist/config/load.d.ts +0 -2
  12. package/dist/config/load.js +2 -31
  13. package/dist/config/schema.d.ts +1 -14
  14. package/dist/config/schema.js +0 -1
  15. package/dist/config/store.js +2 -0
  16. package/dist/logging/install.d.ts +2 -2
  17. package/dist/logging/install.js +2 -2
  18. package/dist/todo/follow-through.d.ts +0 -4
  19. package/dist/todo/follow-through.js +0 -4
  20. package/dist/todo/limits.d.ts +0 -10
  21. package/dist/todo/limits.js +0 -10
  22. package/dist/todo/prompt.d.ts +0 -15
  23. package/dist/ui/ActivityLine.d.ts +26 -34
  24. package/dist/ui/ActivityLine.js +30 -19
  25. package/dist/ui/App.d.ts +1 -41
  26. package/dist/ui/App.js +66 -128
  27. package/dist/ui/BottomStatusRow.d.ts +1 -3
  28. package/dist/ui/BottomStatusRow.js +4 -8
  29. package/dist/ui/Composer.d.ts +28 -1
  30. package/dist/ui/Composer.js +43 -17
  31. package/dist/ui/Header.d.ts +2 -16
  32. package/dist/ui/Header.js +3 -18
  33. package/dist/ui/Logo.d.ts +1 -17
  34. package/dist/ui/Logo.js +0 -16
  35. package/dist/ui/ModeChip.d.ts +7 -18
  36. package/dist/ui/ModeChip.js +9 -0
  37. package/dist/ui/PromptInput.d.ts +2 -28
  38. package/dist/ui/PromptInput.js +4 -7
  39. package/dist/ui/StatusBar.d.ts +0 -11
  40. package/dist/ui/ToastStack.d.ts +2 -18
  41. package/dist/ui/ToastStack.js +4 -11
  42. package/dist/ui/Transcript.d.ts +5 -71
  43. package/dist/ui/Transcript.js +9 -199
  44. package/dist/ui/entries/AssistantEntry.d.ts +0 -23
  45. package/dist/ui/entries/AssistantEntry.js +3 -4
  46. package/dist/ui/entries/DiffView.d.ts +1 -7
  47. package/dist/ui/entries/DiffView.js +2 -2
  48. package/dist/ui/entries/ToolCard.d.ts +0 -6
  49. package/dist/ui/entries/ToolCard.js +4 -13
  50. package/dist/ui/frame-differ.d.ts +13 -0
  51. package/dist/ui/frame-differ.js +36 -6
  52. package/dist/ui/ink-runtime.d.ts +1 -0
  53. package/dist/ui/ink-runtime.js +21 -0
  54. package/dist/ui/layout/AppShell.d.ts +1 -10
  55. package/dist/ui/layout/AppShell.js +2 -5
  56. package/dist/ui/layout/OverlayFrame.d.ts +1 -25
  57. package/dist/ui/layout/OverlayFrame.js +0 -24
  58. package/dist/ui/layout/ScrollViewport.d.ts +3 -0
  59. package/dist/ui/layout/ScrollViewport.js +17 -5
  60. package/dist/ui/layout/frame.d.ts +0 -22
  61. package/dist/ui/layout/frame.js +0 -36
  62. package/dist/ui/layout/todo-layout.d.ts +1 -3
  63. package/dist/ui/layout/todo-layout.js +7 -8
  64. package/dist/ui/layout/viewport-geometry.d.ts +1 -1
  65. package/dist/ui/layout/viewport-geometry.js +1 -1
  66. package/dist/ui/live-clamp.d.ts +1 -29
  67. package/dist/ui/live-clamp.js +1 -29
  68. package/dist/ui/overlays/HelpOverlay.js +1 -0
  69. package/dist/ui/run-status-row.d.ts +61 -0
  70. package/dist/ui/run-status-row.js +70 -0
  71. package/dist/ui/transcript-text.d.ts +0 -8
  72. package/dist/ui/transcript-text.js +0 -8
  73. package/dist/ui/use-startup-notices.d.ts +1 -57
  74. package/dist/ui/use-startup-notices.js +3 -54
  75. package/dist/ui/use-wheel-routing.d.ts +1 -1
  76. package/package.json +2 -2
  77. package/dist/ui/TodoStrip.d.ts +0 -36
  78. 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,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';
@@ -96,7 +96,6 @@ function toFlags(opts) {
96
96
  confirm: opts.confirm,
97
97
  toolTimeout: opts.toolTimeout,
98
98
  idleTimeout: opts.idleTimeout,
99
- fullscreen: opts.fullscreen,
100
99
  exitTranscript: opts.exitTranscript,
101
100
  compact: opts.compact,
102
101
  hints: opts.hints,
@@ -230,14 +229,8 @@ function makeController(flags, opts = { interactive: true }) {
230
229
  // resolved a bootstrap level — it ran before commander and before `.env` were
231
230
  // readable — and without this hand-off the fully resolved one never applies.
232
231
  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';
232
+ // Headless controllers never expose the task panel.
233
+ const todoPanelCapable = opts.interactive;
241
234
  const confirmBridge = { handler: null };
242
235
  // `cancelPending` is replaced by the App's own implementation on mount; this
243
236
  // no-op keeps the object total so nothing has to null-check it.
@@ -389,11 +382,15 @@ function forceVtInputForThisConsole() {
389
382
  * renders Ink, `runOneShot()` goes to `runHeadless()`, which never does.
390
383
  */
391
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
+ }
392
390
  const { controller, confirmBridge, humanInputBridge } = makeController(flags);
393
391
  const config = controller.getConfig();
394
- const mode = decideRenderMode({ fullscreen: config.fullscreen }, process.env, process.stdout);
395
392
  const logger = getLogger();
396
- 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 });
397
394
  attachAgentEvents(logger, controller);
398
395
  // The team stream is CLI-local (D-10), so `attachAgentEvents` cannot see it.
399
396
  // Without this a dispatch leaves NO trace in the log file at all — the wrong
@@ -442,8 +439,7 @@ function runInteractive(flags, extras = {}) {
442
439
  // a console this process just repaired.
443
440
  const vtInputByNode = supportsWindowsVtInput(process.platform, process.versions.node);
444
441
  const vtInputSupported = vtInputByNode || forceVtInputForThisConsole();
445
- const wantMouse = mode === 'fullscreen' &&
446
- config.mouse &&
442
+ const wantMouse = config.mouse &&
447
443
  !!process.stdout.isTTY &&
448
444
  !!process.stdin.isTTY &&
449
445
  vtInputSupported;
@@ -492,21 +488,13 @@ function runInteractive(flags, extras = {}) {
492
488
  onCopied: null,
493
489
  requestRedraw: null,
494
490
  };
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
491
+ const frameWriter = config.diffRender
507
492
  ? wrapStdoutForFrames(process.stdout, createFrameDiffer({
508
493
  sync: config.syncOutput && process.env.TERM !== 'dumb',
509
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,
510
498
  // The console bridge (`App.tsx`, full-screen only) turns this into
511
499
  // `dispatch({type:'notice', level:'warn'})`, which is the only legal
512
500
  // user-visible channel with the TUI mounted — a direct write would
@@ -539,11 +527,11 @@ function runInteractive(flags, extras = {}) {
539
527
  // than printing zeroes that read like a writer doing nothing (§5.4).
540
528
  if (frameWriter)
541
529
  setFrameStatsProvider(frameWriter.stats);
542
- const frameObserver = mode === 'fullscreen' ? createFrameObserver({
530
+ const frameObserver = createFrameObserver({
543
531
  stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
544
- }) : null;
532
+ });
545
533
  const disposeFrameWriter = () => {
546
- frameObserver?.dispose();
534
+ frameObserver.dispose();
547
535
  pointerRouter?.dispose();
548
536
  scrollbar.controller?.dispose();
549
537
  if (!frameWriter)
@@ -552,7 +540,7 @@ function runInteractive(flags, extras = {}) {
552
540
  frameWriter.dispose();
553
541
  };
554
542
  const replayTranscript = () => {
555
- if (replayed || mode !== 'fullscreen' || !config.exitTranscript)
543
+ if (replayed || !config.exitTranscript)
556
544
  return;
557
545
  replayed = true;
558
546
  const snapshot = readExitSnapshot();
@@ -571,7 +559,7 @@ function runInteractive(flags, extras = {}) {
571
559
  ...(snapshot.droppedEntries ? { droppedEntries: snapshot.droppedEntries } : {}),
572
560
  }));
573
561
  };
574
- if (mode === 'fullscreen') {
562
+ {
575
563
  // `mouse` here means "a mouse-parsing filter is installed", NOT "the user
576
564
  // wants mouse support" — see `AltScreenOptions`. `screen.ts` must never
577
565
  // derive it for itself, or a later refactor re-opens the gap I-8 closes.
@@ -585,7 +573,7 @@ function runInteractive(flags, extras = {}) {
585
573
  screen = enterAltScreen(process.stdout, {
586
574
  mouse: mouseOn,
587
575
  motion: mouseOn,
588
- bracketedPaste: pasteOn && mode === 'fullscreen',
576
+ bracketedPaste: pasteOn,
589
577
  });
590
578
  const restore = () => screen?.restore();
591
579
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
@@ -610,42 +598,6 @@ function runInteractive(flags, extras = {}) {
610
598
  process.exit(128 + signo);
611
599
  });
612
600
  }
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
601
  const writeForeign = frameWriter
650
602
  ? (text) => { frameObserver?.invalidate(); frameWriter.writeForeign(text); }
651
603
  : process.stdout.isTTY
@@ -695,45 +647,30 @@ function runInteractive(flags, extras = {}) {
695
647
  selectionBridge.controller?.dispose();
696
648
  selectionBridge.controller = null;
697
649
  };
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;
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
+ };
737
674
  const updateBridge = { service: null, onAttach: null };
738
675
  let updateService = null;
739
676
  let interactiveClosed = false;
@@ -761,12 +698,7 @@ function runInteractive(flags, extras = {}) {
761
698
  config.update.mode !== 'off';
762
699
  let instance;
763
700
  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,
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,
770
702
  // Present ONLY when this console swallows `CSI Z` and mouse reports.
771
703
  // Passed in rather than probed inside `App` so that every test renders
772
704
  // the same tree on every machine — reading `process.versions.node` from
@@ -780,17 +712,11 @@ function runInteractive(flags, extras = {}) {
780
712
  // straight to stdout and permanently shifts the fixed frame's accounting.
781
713
  {
782
714
  exitOnCtrlC: false,
783
- patchConsole: mode === 'inline',
715
+ patchConsole: false,
784
716
  // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
785
717
  // every existing path is provably unchanged.
786
718
  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 } : {}),
719
+ stdout: frameObserver.stdout,
794
720
  });
795
721
  }
796
722
  catch (error) {
@@ -968,7 +894,6 @@ const CONFIG_SET_KEYS = new Set([
968
894
  'confirmTools',
969
895
  'toolTimeoutMs',
970
896
  'idleTimeoutMs',
971
- 'fullscreen',
972
897
  'exitTranscript',
973
898
  'transcriptWindow',
974
899
  // tui-render-performance L1 / L4. Listed here AND cased in the switch
@@ -1189,9 +1114,6 @@ function runConfigSet(key, value) {
1189
1114
  case 'idleTimeoutMs':
1190
1115
  patch.idleTimeoutMs = coercePositiveInt(value, 210_000);
1191
1116
  break;
1192
- case 'fullscreen':
1193
- patch.fullscreen = value === 'true' || value === '1';
1194
- break;
1195
1117
  case 'exitTranscript':
1196
1118
  patch.exitTranscript = value === 'true' || value === '1';
1197
1119
  break;
@@ -1480,30 +1402,16 @@ function buildProgram() {
1480
1402
  .option('--tool-timeout <ms>', 'Per-tool executor ceiling (default 180000)')
1481
1403
  .option('--idle-timeout <ms>', 'Watchdog idle timeout (auto-raised to >= tool-timeout+30s)')
1482
1404
  .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
1405
  .option('--compact', 'Compact transcript density (no blank rows between turns)')
1487
1406
  .option('--no-compact', 'Comfortable transcript density')
1488
1407
  .option('--hints', 'Always show the composer hint row')
1489
1408
  .option('--no-hints', 'Hide the composer hint row')
1490
- // Positive form first, same tri-state reason as `--fullscreen` above.
1491
1409
  .option('--show-thinking', 'Show the reasoning blocks the model returns')
1492
1410
  .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
1411
  .option('--live-tool-output', 'Show a running command output tail (the default)')
1498
1412
  .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
1413
  .option('--mouse', 'Wheel scrolls the transcript (full-screen mode)')
1501
1414
  .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
1415
  .option('--mouse-select', 'Drag with the mouse to select text; releasing copies it')
1508
1416
  .option('--no-mouse-select', 'Keep wheel scrolling, but leave drag-select off')
1509
1417
  // Positive form FIRST, same tri-state reason as `--mouse-select` above:
@@ -1512,16 +1420,7 @@ function buildProgram() {
1512
1420
  // silence, and silently overriding a stored `false` on every run.
1513
1421
  .option('--paste', 'Collapse large pastes into a placeholder (the default)')
1514
1422
  .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
1423
  .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
1424
  .option('--plan', 'Start the session in PLAN mode (read-only research + review)')
1526
1425
  .option('--no-plan', 'Start the session in BUILD mode (overrides planModeDefault)')
1527
1426
  // BOTH FORMS, positive first, and the pair is mandatory (team-subagents
@@ -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 = {
@@ -89,7 +89,7 @@ export interface CommandContext {
89
89
  /**
90
90
  * Mouse capture, for `/mouse` (tui-selection-and-scroll-follow §6.2 / G3).
91
91
  *
92
- * OPTIONAL, and legitimately absent: inline mode, `--no-mouse`, a non-TTY, a
92
+ * OPTIONAL, and legitimately absent: `--no-mouse`, a non-TTY, a
93
93
  * Windows console that cannot deliver reports, and every test that builds this
94
94
  * context by literal. `/mouse` has to have something honest to say when it is
95
95
  * missing, which is the same reason `update` above it is optional.
@@ -101,7 +101,7 @@ export interface CommandContext {
101
101
  * NOT `process.stdout.write`. In full-screen mode stdout is a `Proxy` in front
102
102
  * of the frame differ, and an unrecognised chunk there is counted as a foreign
103
103
  * write and prints a diagnostic at the user the first time they copy anything
104
- * (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
105
105
  * stream is handed over directly.
106
106
  */
107
107
  writeForeign?: (text: string) => void;