@aragon-agent/cli 0.6.28 → 0.6.31

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 (80) hide show
  1. package/CHANGELOG.md +2123 -1975
  2. package/README.md +81 -14
  3. package/dist/agent/project-guidance-prompt.js +20 -20
  4. package/dist/agent/reducer.js +47 -2
  5. package/dist/cli.js +169 -56
  6. package/dist/commands/builtins.js +4 -0
  7. package/dist/commands/perf.d.ts +25 -1
  8. package/dist/commands/perf.js +26 -2
  9. package/dist/commands/registry.d.ts +6 -0
  10. package/dist/config/env.js +4 -0
  11. package/dist/config/load.d.ts +3 -1
  12. package/dist/config/load.js +12 -1
  13. package/dist/config/schema.d.ts +27 -0
  14. package/dist/config/schema.js +9 -0
  15. package/dist/config/ui-state.d.ts +9 -0
  16. package/dist/config/ui-state.js +9 -0
  17. package/dist/input/limits.d.ts +20 -0
  18. package/dist/input/limits.js +20 -0
  19. package/dist/input/stdin-filter.js +68 -5
  20. package/dist/ui/App.d.ts +11 -1
  21. package/dist/ui/App.js +155 -67
  22. package/dist/ui/AutocompletePopup.js +1 -1
  23. package/dist/ui/Header.js +7 -2
  24. package/dist/ui/Markdown.js +30 -3
  25. package/dist/ui/PromptInput.js +41 -3
  26. package/dist/ui/ScrollbackShell.d.ts +28 -0
  27. package/dist/ui/ScrollbackShell.js +10 -0
  28. package/dist/ui/Transcript.js +7 -0
  29. package/dist/ui/frame-differ.d.ts +12 -0
  30. package/dist/ui/layout/Gutter.js +1 -1
  31. package/dist/ui/layout/OverlayFrame.d.ts +15 -0
  32. package/dist/ui/layout/OverlayFrame.js +18 -0
  33. package/dist/ui/layout/ScrollIndicator.js +10 -3
  34. package/dist/ui/layout/budget.d.ts +15 -0
  35. package/dist/ui/layout/budget.js +15 -0
  36. package/dist/ui/layout/header-hints.d.ts +9 -0
  37. package/dist/ui/layout/header-hints.js +14 -1
  38. package/dist/ui/layout/status-layout.js +63 -2
  39. package/dist/ui/markdown-blocks.d.ts +26 -5
  40. package/dist/ui/markdown-blocks.js +110 -33
  41. package/dist/ui/overlays/HelpOverlay.d.ts +1 -1
  42. package/dist/ui/overlays/HelpOverlay.js +21 -3
  43. package/dist/ui/screen.d.ts +16 -0
  44. package/dist/ui/screen.js +32 -0
  45. package/dist/ui/scrollback/budget.d.ts +43 -0
  46. package/dist/ui/scrollback/budget.js +44 -0
  47. package/dist/ui/scrollback/drain.d.ts +32 -0
  48. package/dist/ui/scrollback/drain.js +64 -0
  49. package/dist/ui/scrollback/exit-summary.d.ts +23 -0
  50. package/dist/ui/scrollback/exit-summary.js +25 -0
  51. package/dist/ui/scrollback/limits.d.ts +25 -0
  52. package/dist/ui/scrollback/limits.js +25 -0
  53. package/dist/ui/scrollback/live-preview.d.ts +26 -0
  54. package/dist/ui/scrollback/live-preview.js +106 -0
  55. package/dist/ui/scrollback/renderer.d.ts +38 -0
  56. package/dist/ui/scrollback/renderer.js +158 -0
  57. package/dist/ui/scrollback/scrollback-writer.d.ts +74 -0
  58. package/dist/ui/scrollback/scrollback-writer.js +263 -0
  59. package/dist/ui/scrollback/settled-boundary.d.ts +42 -0
  60. package/dist/ui/scrollback/settled-boundary.js +106 -0
  61. package/dist/ui/scrollback/use-scrollback-view.d.ts +92 -0
  62. package/dist/ui/scrollback/use-scrollback-view.js +263 -0
  63. package/dist/ui/scrollback/writer-shapes.d.ts +61 -0
  64. package/dist/ui/scrollback/writer-shapes.js +114 -0
  65. package/dist/ui/stdout-frame-writer.js +12 -2
  66. package/dist/ui/terminal-plan.d.ts +52 -0
  67. package/dist/ui/terminal-plan.js +49 -0
  68. package/dist/ui/transcript-text.d.ts +11 -0
  69. package/dist/ui/transcript-text.js +21 -7
  70. package/dist/ui/use-startup-notices.d.ts +12 -1
  71. package/dist/ui/use-startup-notices.js +28 -2
  72. package/dist/ui/use-wheel-routing.d.ts +10 -7
  73. package/dist/ui/use-wheel-routing.js +11 -6
  74. package/package.json +2 -2
  75. package/skills/project-indexer/SKILL.md +163 -163
  76. package/skills/project-indexer/templates/cleancode-overrides.md +46 -46
  77. package/skills/project-indexer/templates/cleancode-template.md +94 -94
  78. package/skills/project-indexer/templates/config-template.md +69 -69
  79. package/skills/project-indexer/templates/index-template.md +129 -129
  80. package/skills/skill-creator/SKILL.md +96 -96
package/dist/cli.js CHANGED
@@ -29,7 +29,7 @@ import { COMPACTION_CONFIG_SET_KEYS, FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS,
29
29
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
30
30
  import { getLogger } from './logging/logger.js';
31
31
  import { runLogsCommand } from './logging/cli-commands.js';
32
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
32
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampContextWindow, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, isAutoToken, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SCROLL_MODE, DEFAULT_SCROLL_RESUME_MS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
33
33
  import { makeGetApiKey } from './config/load.js';
34
34
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
35
35
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -49,13 +49,17 @@ import { createBranchLauncher } from './session/branch-launch.js';
49
49
  import { EMPTY_USAGE_TOTAL } from './agent/reducer.js';
50
50
  import { createFrameDiffer } from './ui/frame-differ.js';
51
51
  import { wrapStdoutForFrames } from './ui/stdout-frame-writer.js';
52
- import { setFrameStatsProvider } from './commands/perf.js';
52
+ import { setFrameStatsProvider, setScrollbackStatsProvider } from './commands/perf.js';
53
53
  import { tryCreateStdinFilter } from './input/stdin-filter.js';
54
54
  import { enableKeyboardEnhancement, NOOP_KEYBOARD_ENHANCEMENT, } from './input/keyboard-enhancement.js';
55
55
  import { createScrollbarBridge } from './ui/scrollbar-controller.js';
56
56
  import { createPointerRouter } from './input/pointer-router.js';
57
57
  import { createFrameObserver } from './ui/frame-observer.js';
58
- import { enterAltScreen, writeExitTranscript } from './ui/screen.js';
58
+ import { enterAltScreen, enterNativeScreen, writeExitTranscript, } from './ui/screen.js';
59
+ import { resolveTerminalPlan } from './ui/terminal-plan.js';
60
+ import { createScrollbackDrain } from './ui/scrollback/drain.js';
61
+ import { createScrollbackWriter } from './ui/scrollback/scrollback-writer.js';
62
+ import { createScrollbackBridge, } from './ui/scrollback/use-scrollback-view.js';
59
63
  import { supportsWindowsVtInput } from './ui/win-vt-input.js';
60
64
  import { forceWindowsVtInput } from './ui/win-vt-force.js';
61
65
  import { readExitSnapshot } from './ui/exit-snapshot.js';
@@ -88,6 +92,19 @@ function readVersion() {
88
92
  }
89
93
  }
90
94
  const VERSION = readVersion();
95
+ /**
96
+ * `--scroll-mode` is the one flag validated at parse time (tui-native-terminal-scroll
97
+ * §6.1): a typo must be an exit-2 sentence, not a silent fallback to the default.
98
+ */
99
+ function parseScrollModeFlag(raw) {
100
+ if (raw === undefined)
101
+ return undefined;
102
+ const mode = clampScrollMode(raw, DEFAULT_SCROLL_MODE);
103
+ if (mode === raw.trim().toLowerCase())
104
+ return mode;
105
+ process.stderr.write(`Invalid --scroll-mode "${raw}" (expected native or app).\n`);
106
+ process.exit(2);
107
+ }
91
108
  function toFlags(opts) {
92
109
  return {
93
110
  provider: opts.provider,
@@ -186,6 +203,8 @@ function toFlags(opts) {
186
203
  // user whose terminal renders the diffed frame wrongly, and a user who has
187
204
  // been told to pass it and sees no change has no next move.
188
205
  diffRender: opts.diffRender,
206
+ // Third site for `--scroll-mode`, the same trap the sentence above records.
207
+ scrollMode: parseScrollModeFlag(opts.scrollMode),
189
208
  syncOutput: opts.syncOutput,
190
209
  // Site three of three for `--update` / `--no-update` — SEVEN features, one
191
210
  // trap (`mouse` P1-2, `team` P1-4, `todo` C-9, render L4, retry, fast, now
@@ -431,8 +450,10 @@ function runInteractive(flags, extras = {}) {
431
450
  const config = controller.getConfig();
432
451
  const caps = resolveTuiCapabilities({}, config.color);
433
452
  const logger = getLogger();
453
+ const scrollMode = config.scrollMode ?? DEFAULT_SCROLL_MODE;
434
454
  logger.info('cli', 'run_interactive', {
435
- mode: 'fullscreen', provider: config.provider, model: config.model,
455
+ mode: scrollMode === 'native' ? 'native' : 'fullscreen',
456
+ provider: config.provider, model: config.model,
436
457
  unicode: caps.unicode, colorLevel: caps.colorLevel,
437
458
  });
438
459
  attachAgentEvents(logger, controller);
@@ -513,6 +534,37 @@ function runInteractive(flags, extras = {}) {
513
534
  };
514
535
  let screen = null;
515
536
  let replayed = false;
537
+ /**
538
+ * Native-scroll exit, shared by EVERY restore path (normal exit, `process.on
539
+ * ('exit')`, the signal terminator, the crash handler, `failInteractive`).
540
+ * Idempotent: the writer's `finalize` returns `''` the second time.
541
+ *
542
+ * Order (RV-9): publish the last unsettled entries to the feed, take the summary
543
+ * (lazily, and a failure only costs the summary), then write the finalize bytes
544
+ * through the REAL stdout. After `finalize` the writer swallows every Ink write,
545
+ * so it does not matter whether Ink has unmounted yet; callers must not add an
546
+ * asynchronous wait for it.
547
+ */
548
+ const finalizeNativeScreen = () => {
549
+ const bridge = scrollbackBridge;
550
+ if (!bridge)
551
+ return;
552
+ try {
553
+ bridge.settleAllNow();
554
+ }
555
+ catch { /* the feed keeps whatever was queued */ }
556
+ let summary = [];
557
+ try {
558
+ summary = bridge.getSummaryRows();
559
+ }
560
+ catch { /* degrade to no summary */ }
561
+ try {
562
+ const bytes = bridge.writer.finalize(summary);
563
+ if (bytes.length > 0)
564
+ process.stdout.write(bytes);
565
+ }
566
+ catch { /* a closed stdout must never turn into a crash on the exit path */ }
567
+ };
516
568
  // --- Mouse wheel region routing (mouse-wheel-region-routing §4.2). -------
517
569
  //
518
570
  // BUILD THE FILTER FIRST, THEN ENABLE REPORTING. The order is invariant I-8
@@ -553,10 +605,23 @@ function runInteractive(flags, extras = {}) {
553
605
  // a console this process just repaired.
554
606
  const vtInputByNode = supportsWindowsVtInput(process.platform, process.versions.node);
555
607
  const vtInputSupported = vtInputByNode || forceVtInputForThisConsole();
556
- const wantMouse = config.mouse &&
557
- !!process.stdout.isTTY &&
558
- !!process.stdin.isTTY &&
559
- vtInputSupported;
608
+ // THE ONE DECISION POINT for screen mode, mouse, paste, key enhancement and the
609
+ // frame writer (tui-native-terminal-scroll D10). In `app` mode every field below
610
+ // is the pre-feature expression term for term (`terminal-plan.test.ts` pins it).
611
+ const plan = resolveTerminalPlan({
612
+ scrollMode,
613
+ configMouse: config.mouse,
614
+ configMouseSelect: config.mouseSelect,
615
+ configPaste: config.paste,
616
+ configKeyboardEnhancement: config.keyboardEnhancement,
617
+ configDiffRender: config.diffRender,
618
+ stdinIsTTY: !!process.stdin.isTTY,
619
+ stdoutIsTTY: !!process.stdout.isTTY,
620
+ platform: process.platform,
621
+ vtInputSupported,
622
+ });
623
+ const native = plan.scrollMode === 'native';
624
+ const wantMouse = plan.mouseReporting;
560
625
  // --- Paste handling (tui-paste-handling section 5.1.1 / D-13 / D-17). ----
561
626
  //
562
627
  // THE GATE IS WIDER THAN THE MOUSE'S, AND `paste` DEFAULTS TO TRUE. Paste
@@ -571,7 +636,7 @@ function runInteractive(flags, extras = {}) {
571
636
  // would flip true: `?1000h`/`?1006h`/`?1002h` written, terminal selection taken
572
637
  // away (the ONLY reason anyone passes `--no-mouse`), and a startup notice
573
638
  // advising the user about a mode they just disabled.
574
- const wantPaste = config.paste && !!process.stdin.isTTY;
639
+ const wantPaste = plan.bracketedPaste;
575
640
  const pasteBridge = { notify: null };
576
641
  // --- Keyboard enhancement (Shift+Enter newline). --------------------------
577
642
  //
@@ -584,9 +649,7 @@ function runInteractive(flags, extras = {}) {
584
649
  // Elsewhere the push is kitty disambiguate + modifyOtherKeys, which a
585
650
  // terminal that implements neither ignores (verified byte-identical on
586
651
  // conhost), so no capability gate is possible or needed there.
587
- const wantEnhancedKeys = config.keyboardEnhancement &&
588
- !!process.stdin.isTTY &&
589
- (process.platform !== 'win32' || vtInputSupported);
652
+ const wantEnhancedKeys = plan.enhancedKeys;
590
653
  const filter = process.stdin.isTTY
591
654
  ? tryCreateStdinFilter(process.stdin, { mouse: wantMouse, paste: wantPaste, enhancedKeys: wantEnhancedKeys }, (reason) => logger.warn('cli', 'stdin_filter_failed', { reason }), pasteBridge)
592
655
  : null;
@@ -607,6 +670,7 @@ function runInteractive(flags, extras = {}) {
607
670
  //
608
671
  // Selection owns its controller, decoration and notice. Button motion follows
609
672
  // mouseOn independently so --no-mouse-select still permits scrollbar dragging.
673
+ // `mouseOn` is already false in native mode, so this needs no `plan.dragSelect` term.
610
674
  const wantSelect = mouseOn && config.mouseSelect;
611
675
  let mouseCaptured = mouseOn;
612
676
  const scrollbar = createScrollbarBridge(() => mouseCaptured);
@@ -618,59 +682,94 @@ function runInteractive(flags, extras = {}) {
618
682
  onCopied: null,
619
683
  requestRedraw: null,
620
684
  };
621
- const frameWriter = config.diffRender
622
- ? wrapStdoutForFrames(process.stdout, createFrameDiffer({
685
+ // Native scroll: a drain + writer pair replaces the full-screen differ. The bridge
686
+ // is the only object shared with the React tree (tui-native-terminal-scroll §6.2).
687
+ let scrollbackBridge;
688
+ if (plan.differ === 'scrollback') {
689
+ const drain = createScrollbackDrain();
690
+ const writer = createScrollbackWriter({
623
691
  sync: config.syncOutput && process.env.TERM !== 'dumb',
624
692
  rows: () => process.stdout.rows,
625
- // Rows that fill the line skip `CSI K` so the scrollbar's last column
626
- // is not erased on pending-wrap terminals (frame-differ I-4).
627
693
  cols: () => process.stdout.columns,
628
- // The console bridge (`App.tsx`, full-screen only) turns this into
629
- // `dispatch({type:'notice', level:'warn'})`, which is the only legal
630
- // user-visible channel with the TUI mounted — a direct write would
631
- // itself be the foreign write being reported. Latched on the 0->1
632
- // edge inside the differ: a notice per fallback would be a repaint
633
- // storm triggered by a repaint problem (§5.6).
694
+ fullEveryFrame: plan.fullEveryFrame,
695
+ drain,
634
696
  onFirstFallback: () => console.warn(FRAME_FALLBACK_NOTICE),
635
- // ABSENT ENTIRELY without drag-select, not a no-op function: the
636
- // differ's identity path then allocates nothing and copies nothing,
637
- // which is what makes `mouseSelect: false` a statement about the code
638
- // rather than about how cheap an empty function is.
639
- ...(wantSelect
640
- ? {
641
- decorate: (lines) => selectionBridge.controller?.decorate(lines) ?? lines,
642
- // P1-7: a pass-through frame writes the RAW Ink frame verbatim,
643
- // so the highlight is wiped off the terminal while the
644
- // controller still believes it is painted. Deferred out of the
645
- // current stack because this fires from inside `transform`,
646
- // which is inside Ink's own `stdout.write`.
647
- onInvalidate: () => {
648
- const controller = selectionBridge.controller;
649
- if (controller)
650
- setImmediate(() => controller.clear());
651
- },
652
- }
653
- : {}),
654
- }))
655
- : null;
697
+ onGuardTrip: () => scrollbackBridge?.onGuardTrip?.(),
698
+ // Deferred: static rows can be queued from inside a React commit, and the
699
+ // repaint writes to the stream Ink is in the middle of writing to.
700
+ requestRepaint: () => { setImmediate(() => frameWriter?.repaint()); },
701
+ });
702
+ scrollbackBridge = createScrollbackBridge({
703
+ writer, drain, requestFlush: () => { setImmediate(() => frameWriter?.repaint()); },
704
+ });
705
+ }
706
+ const frameWriter = scrollbackBridge
707
+ ? wrapStdoutForFrames(process.stdout, scrollbackBridge.writer)
708
+ : plan.differ === 'fullscreen'
709
+ ? wrapStdoutForFrames(process.stdout, createFrameDiffer({
710
+ sync: config.syncOutput && process.env.TERM !== 'dumb',
711
+ rows: () => process.stdout.rows,
712
+ // Rows that fill the line skip `CSI K` so the scrollbar's last column
713
+ // is not erased on pending-wrap terminals (frame-differ I-4).
714
+ cols: () => process.stdout.columns,
715
+ // The console bridge (`App.tsx`, full-screen only) turns this into
716
+ // `dispatch({type:'notice', level:'warn'})`, which is the only legal
717
+ // user-visible channel with the TUI mounted — a direct write would
718
+ // itself be the foreign write being reported. Latched on the 0->1
719
+ // edge inside the differ: a notice per fallback would be a repaint
720
+ // storm triggered by a repaint problem (§5.6).
721
+ onFirstFallback: () => console.warn(FRAME_FALLBACK_NOTICE),
722
+ // ABSENT ENTIRELY without drag-select, not a no-op function: the
723
+ // differ's identity path then allocates nothing and copies nothing,
724
+ // which is what makes `mouseSelect: false` a statement about the code
725
+ // rather than about how cheap an empty function is.
726
+ ...(wantSelect
727
+ ? {
728
+ decorate: (lines) => selectionBridge.controller?.decorate(lines) ?? lines,
729
+ // P1-7: a pass-through frame writes the RAW Ink frame verbatim,
730
+ // so the highlight is wiped off the terminal while the
731
+ // controller still believes it is painted. Deferred out of the
732
+ // current stack because this fires from inside `transform`,
733
+ // which is inside Ink's own `stdout.write`.
734
+ onInvalidate: () => {
735
+ const controller = selectionBridge.controller;
736
+ if (controller)
737
+ setImmediate(() => controller.clear());
738
+ },
739
+ }
740
+ : {}),
741
+ }))
742
+ : null;
656
743
  // `null` when no writer was built, so `/perf` can say "diff render off" rather
657
744
  // than printing zeroes that read like a writer doing nothing (§5.4).
658
745
  if (frameWriter)
659
746
  setFrameStatsProvider(frameWriter.stats);
660
- const frameObserver = createFrameObserver({
661
- stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
662
- });
747
+ if (scrollbackBridge) {
748
+ const { writer } = scrollbackBridge;
749
+ setScrollbackStatsProvider(() => ({
750
+ ...writer.scrollbackStats(), regionRows: writer.regionRows(),
751
+ }));
752
+ }
753
+ // The observer only serves the full-screen scrollbar geometry; native has none.
754
+ const frameObserver = native
755
+ ? null
756
+ : createFrameObserver({
757
+ stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
758
+ });
663
759
  const disposeFrameWriter = () => {
664
- frameObserver.dispose();
760
+ frameObserver?.dispose();
665
761
  pointerRouter?.dispose();
666
762
  scrollbar.controller?.dispose();
667
763
  if (!frameWriter)
668
764
  return;
669
765
  setFrameStatsProvider(null);
766
+ setScrollbackStatsProvider(null);
670
767
  frameWriter.dispose();
671
768
  };
672
769
  const replayTranscript = () => {
673
- if (replayed || !config.exitTranscript)
770
+ // Native mode never replays: the conversation is already in the scrollback, and
771
+ // a replay would print it twice (D9).
772
+ if (replayed || !config.exitTranscript || native)
674
773
  return;
675
774
  replayed = true;
676
775
  const snapshot = readExitSnapshot();
@@ -710,17 +809,21 @@ function runInteractive(flags, extras = {}) {
710
809
  // paste at its default `true` still builds a filter, and passing the handle
711
810
  // here would write `?1000h` at a user who disabled the mouse precisely to
712
811
  // keep their terminal's own selection.
713
- screen = enterAltScreen(process.stdout, {
714
- mouse: mouseOn,
715
- motion: mouseOn,
716
- bracketedPaste: pasteOn,
717
- });
812
+ screen = plan.altScreen
813
+ ? enterAltScreen(process.stdout, {
814
+ mouse: mouseOn,
815
+ motion: mouseOn,
816
+ bracketedPaste: pasteOn,
817
+ })
818
+ // Native scroll: no alternate screen, no mouse (D6). Only DEC 2004.
819
+ : enterNativeScreen(process.stdout, { bracketedPaste: pasteOn });
718
820
  // Input-encoding modes unwind FIRST, for the same reason the mouse
719
821
  // reporting does inside `screen.restore()`: the terminal must stop
720
822
  // sending enhanced encodings before anything else tears the reader
721
823
  // down, or a keystroke taken in that instant survives into the shell.
722
824
  const restore = () => {
723
825
  keyboardEnhancement.restore();
826
+ finalizeNativeScreen();
724
827
  screen?.restore();
725
828
  };
726
829
  // Four idempotent restore paths (§4.4). The signal hook is NOT redundant:
@@ -838,7 +941,8 @@ function runInteractive(flags, extras = {}) {
838
941
  interactiveClosed = true;
839
942
  logger.error('cli', 'render_failed', { error: String(error) });
840
943
  for (const cleanup of [
841
- () => screen?.restore(), disposeSelection, disposeStdinFilter, disposeFrameWriter,
944
+ finalizeNativeScreen, () => screen?.restore(), disposeSelection, disposeStdinFilter,
945
+ disposeFrameWriter,
842
946
  () => controller.abort(), () => controller.dispose(), () => updateService?.dispose(),
843
947
  () => { process.stdin.pause(); process.stdin.unref?.(); },
844
948
  ]) {
@@ -865,7 +969,7 @@ function runInteractive(flags, extras = {}) {
865
969
  vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
866
970
  // Absent when nothing can refuse a paste, so a `--no-paste` tree is
867
971
  // byte-identical to a build without this feature.
868
- pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge, session: identity, sessionBridge: sessionBridge, ...(startup.initialEntries.length > 0 ? { initialEntries: startup.initialEntries } : {}), ...(startup.notice ? { sessionNotice: startup.notice } : {}) }),
972
+ pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge, ...(scrollbackBridge ? { scrollMode: plan.scrollMode, scrollback: scrollbackBridge } : {}), session: identity, sessionBridge: sessionBridge, ...(startup.initialEntries.length > 0 ? { initialEntries: startup.initialEntries } : {}), ...(startup.notice ? { sessionNotice: startup.notice } : {}) }),
869
973
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
870
974
  // straight to stdout and permanently shifts the fixed frame's accounting.
871
975
  {
@@ -874,7 +978,7 @@ function runInteractive(flags, extras = {}) {
874
978
  // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
875
979
  // every existing path is provably unchanged.
876
980
  stdin: filter?.stdin ?? process.stdin,
877
- stdout: frameObserver.stdout,
981
+ stdout: frameObserver?.stdout ?? frameWriter?.stdout ?? process.stdout,
878
982
  }, caps);
879
983
  }
880
984
  catch (error) {
@@ -955,6 +1059,9 @@ function runInteractive(flags, extras = {}) {
955
1059
  // Also cover a successful immediate exit when launched via dist/cli.js,
956
1060
  // which bypasses the launcher's code-zero boot-guard backstop.
957
1061
  markBootHealthy(VERSION);
1062
+ // Native scroll: finalize BEFORE the screen handle (the writer erases the live
1063
+ // region and prints the summary; the handle then shows the cursor again).
1064
+ finalizeNativeScreen();
958
1065
  screen?.restore();
959
1066
  // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
960
1067
  // and after `restore()` for the reason that call records: the terminal must
@@ -1159,6 +1266,8 @@ const CONFIG_SET_KEYS = new Set([
1159
1266
  // tui-input-flicker-fix §5.1. Listed here AND cased in the switch below, for
1160
1267
  // the reason `transcriptRetain` records three lines up.
1161
1268
  'diffRender',
1269
+ // tui-native-terminal-scroll. Listed here AND cased in the switch below.
1270
+ 'scrollMode',
1162
1271
  'syncOutput',
1163
1272
  // Without these two, `density` and `hints` would be settable only as one-shot
1164
1273
  // flags and never persist -- inconsistent with every other config field.
@@ -1401,6 +1510,9 @@ function runConfigSetValue(key, value) {
1401
1510
  case 'diffRender':
1402
1511
  patch.diffRender = value === 'true' || value === '1';
1403
1512
  break;
1513
+ case 'scrollMode':
1514
+ patch.scrollMode = clampScrollMode(value, DEFAULT_SCROLL_MODE);
1515
+ break;
1404
1516
  case 'syncOutput':
1405
1517
  patch.syncOutput = value === 'true' || value === '1';
1406
1518
  break;
@@ -1790,6 +1902,7 @@ function buildProgram() {
1790
1902
  // one documented escape hatch for a mis-rendering terminal into a no-op.
1791
1903
  .option('--diff-render', 'Repaint only changed rows (default, full-screen only)')
1792
1904
  .option('--no-diff-render', 'Repaint the whole frame every time (pre-fix behavior)')
1905
+ .option('--scroll-mode <mode>', 'Scrolling: native (terminal scrollback, default) or app (full-screen)')
1793
1906
  .option('--sync-output', 'Ask the terminal to present each repaint atomically (the default)')
1794
1907
  .option('--no-sync-output', 'Do not emit DEC 2026 begin/end synchronized update')
1795
1908
  .option('--no-color', 'Disable ANSI color')
@@ -1122,6 +1122,10 @@ const COMMANDS = [
1122
1122
  name: 'mouse',
1123
1123
  description: 'Release the mouse to the terminal, or take it back',
1124
1124
  run: (ctx) => {
1125
+ if (ctx.nativeScroll) {
1126
+ ctx.notify('info', 'Mouse capture is not used in native scroll mode; your terminal handles scrolling and selection.');
1127
+ return;
1128
+ }
1125
1129
  const port = ctx.mouse;
1126
1130
  if (!port) {
1127
1131
  // Inline mode, `--no-mouse`, a non-TTY, or a console that cannot deliver
@@ -34,7 +34,8 @@ export interface PerfSnapshot {
34
34
  heightsEstimated: number;
35
35
  cols: number;
36
36
  /** Geometry. */
37
- mode: 'fullscreen';
37
+ mode: 'fullscreen' | 'native';
38
+ /** Full-screen: the transcript viewport. Native: the live region height. */
38
39
  viewportRows: number;
39
40
  offset: number;
40
41
  /** Right-edge scrollbar diagnostics; read lazily so it reflects the live bridge. */
@@ -74,6 +75,29 @@ export declare function setPerfResetHook(fn: (() => void) | null): void;
74
75
  /** Called by `cli.tsx` after building the writer. Passing `null` detaches. */
75
76
  export declare function setFrameStatsProvider(fn: (() => FrameWriterStats) | null): void;
76
77
  export declare function readFrameStats(): FrameWriterStats | null;
78
+ /**
79
+ * Native-scroll writer counters (tui-native-terminal-scroll §5.1). A third
80
+ * module-level slot for the reason the one above records: the writer lives outside
81
+ * React. `guardHitsOutsideResize > 0` on a session with no resize is the defect
82
+ * signal (a live region reached the terminal height); `clippedRows > 0` means Ink
83
+ * and the terminal disagreed about a character's width (I-S13).
84
+ */
85
+ export interface ScrollbackPerf {
86
+ regionRows: number;
87
+ framesSeed: number;
88
+ framesDiff: number;
89
+ framesFull: number;
90
+ staticRowsWritten: number;
91
+ elidedEntries: number;
92
+ guardHits: number;
93
+ guardHitsOutsideResize: number;
94
+ clippedRows: number;
95
+ fallbacks: number;
96
+ resizes: number;
97
+ repaints: number;
98
+ }
99
+ /** Called by `cli.tsx` in native mode. Passing `null` detaches. */
100
+ export declare function setScrollbackStatsProvider(fn: (() => ScrollbackPerf) | null): void;
77
101
  /** Format a snapshot as the six-line block documented in §5.4 (plus the scrollbar line). */
78
102
  export declare function formatPerfReport(snap: PerfSnapshot): string;
79
103
  export declare const perfCommand: SlashCommand;
@@ -68,6 +68,24 @@ export function setFrameStatsProvider(fn) {
68
68
  export function readFrameStats() {
69
69
  return frameStats ? frameStats() : null;
70
70
  }
71
+ let scrollbackStats = null;
72
+ /** Called by `cli.tsx` in native mode. Passing `null` detaches. */
73
+ export function setScrollbackStatsProvider(fn) {
74
+ scrollbackStats = fn;
75
+ }
76
+ function formatScrollbackLine() {
77
+ const stats = scrollbackStats?.();
78
+ if (!stats)
79
+ return [];
80
+ return [
81
+ `scrollback region ${stats.regionRows} rows - batches seed ${stats.framesSeed}, ` +
82
+ `diff ${stats.framesDiff}, full ${stats.framesFull}, repaint ${stats.repaints} - ` +
83
+ `static rows ${stats.staticRowsWritten} - elided ${stats.elidedEntries}`,
84
+ ` guardHits ${stats.guardHits} (outside resize ${stats.guardHitsOutsideResize}) - ` +
85
+ `clippedRows ${stats.clippedRows} - fallbacks ${stats.fallbacks} - ` +
86
+ `resizes ${stats.resizes}`,
87
+ ];
88
+ }
71
89
  function mb(bytes) {
72
90
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
73
91
  }
@@ -107,7 +125,10 @@ export function formatPerfReport(snap) {
107
125
  const caches = renderCacheStats();
108
126
  const eco = snap.rung > 0 ? ' - eco' : '';
109
127
  const governor = snap.governorEnabled ? '' : ' - governor off';
110
- const scrollbar = snap.scrollbar ? [formatScrollbarLine(snap.scrollbar)] : [];
128
+ const native = snap.mode === 'native';
129
+ const scrollbar = native
130
+ ? ['scrollbar n/a (native scroll)']
131
+ : snap.scrollbar ? [formatScrollbarLine(snap.scrollbar)] : [];
111
132
  return [
112
133
  `render rung ${snap.rung} - interval ${snap.intervalMs}ms - last commit ` +
113
134
  `${Math.round(snap.lastCommitMs)}ms${eco}${governor}`,
@@ -118,8 +139,11 @@ export function formatPerfReport(snap) {
118
139
  `caches md ${caches.markdownEntries} (${mb(caches.markdownBytes)}) - ` +
119
140
  `hl ${caches.highlightEntries} (${mb(caches.highlightBytes)}) - ` +
120
141
  `lines ${caches.lineEntries}`,
121
- `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
142
+ native
143
+ ? `mode native - live region ${snap.viewportRows} rows`
144
+ : `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
122
145
  formatWriterLine(),
146
+ ...formatScrollbackLine(),
123
147
  ...scrollbar,
124
148
  ].join('\n');
125
149
  }
@@ -100,6 +100,12 @@ export interface CommandContext {
100
100
  * missing, which is the same reason `update` above it is optional.
101
101
  */
102
102
  mouse?: MouseCommandPort;
103
+ /**
104
+ * True in native-scroll mode (tui-native-terminal-scroll): the terminal owns
105
+ * scrolling and selection, so there is no capture to toggle. Lets `/mouse` say so
106
+ * instead of the generic "not captured" answer.
107
+ */
108
+ nativeScroll?: boolean;
103
109
  /**
104
110
  * The foreign-write door, for `/copy`'s OSC 52 half (§4.4.5).
105
111
  *
@@ -392,6 +392,10 @@ export function readEnvConfig() {
392
392
  partial.diffRender =
393
393
  diffRender === '1' || diffRender === 'true' || diffRender === 'on' || diffRender === 'yes';
394
394
  }
395
+ // Invalid values are ignored, like every other ARAGON_* key.
396
+ const scrollMode = process.env.ARAGON_SCROLL_MODE?.trim().toLowerCase();
397
+ if (scrollMode === 'native' || scrollMode === 'app')
398
+ partial.scrollMode = scrollMode;
395
399
  const syncOutput = process.env.ARAGON_SYNC_OUTPUT?.trim().toLowerCase();
396
400
  if (syncOutput !== undefined && syncOutput.length > 0) {
397
401
  partial.syncOutput =
@@ -5,7 +5,7 @@
5
5
  * layers, `idleTimeout` is re-derived to be strictly ≥ `toolTimeout` so a
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
- import { type CliConfig } from './schema.js';
8
+ import { type CliConfig, type ScrollMode } from './schema.js';
9
9
  import type { ModelRole } from './model-profiles.js';
10
10
  export interface CliFlags {
11
11
  provider?: string;
@@ -149,6 +149,8 @@ export interface CliFlags {
149
149
  * all — i.e. the documented escape hatch would quietly un-set itself.
150
150
  */
151
151
  diffRender?: boolean;
152
+ /** `--scroll-mode <native|app>`; already validated by `cli.tsx` (exit 2 on junk). */
153
+ scrollMode?: ScrollMode;
152
154
  /** `--sync-output` ⇒ true, `--no-sync-output` ⇒ false, absent ⇒ undefined (§5.2). */
153
155
  syncOutput?: boolean;
154
156
  /**
@@ -6,7 +6,7 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, parseThresholdInput, } from './schema.js';
9
+ import { DEFAULT_CONFIG, DEFAULT_SCROLL_MODE, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampCompactionConfig, clampLogConfig, clampFastConfig, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampBashConfig, clampUpdateConfig, clampTheme, clampThinkingLevel, clampContextWindow, clampMaxTokens, clampMaxRenderInterval, clampScrollMode, clampScrollResumeMs, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, legacyTeamCeilingPatch, parseThresholdInput, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile, updatePersistedConfig } from './store.js';
12
12
  import { projectModelProfiles, resolveModelProfileState, resolveModelRoleKey, } from './model-profile-resolution.js';
@@ -334,6 +334,16 @@ function resolveDiffRender(flags, env, file) {
334
334
  return file.diffRender;
335
335
  return DEFAULT_CONFIG.diffRender;
336
336
  }
337
+ /** flags > env > file > default, like `resolveDiffRender` (tui-native-terminal-scroll §6.1). */
338
+ function resolveScrollMode(flags, env, file) {
339
+ if (flags.scrollMode !== undefined)
340
+ return flags.scrollMode;
341
+ if (env.scrollMode !== undefined)
342
+ return env.scrollMode;
343
+ if (file.scrollMode !== undefined)
344
+ return clampScrollMode(file.scrollMode, DEFAULT_SCROLL_MODE);
345
+ return DEFAULT_SCROLL_MODE;
346
+ }
337
347
  /** Same ladder for the DEC 2026 envelope (§5.1). */
338
348
  function resolveSyncOutput(flags, env, file) {
339
349
  if (flags.syncOutput !== undefined)
@@ -638,6 +648,7 @@ export function loadConfig(flags = {}, options = {}) {
638
648
  renderGovernor: resolveRenderGovernor(flags, env.partial, file),
639
649
  maxRenderIntervalMs: clampMaxRenderInterval(pick(flags.maxRenderInterval, env.partial.maxRenderIntervalMs, file.maxRenderIntervalMs), DEFAULT_CONFIG.maxRenderIntervalMs),
640
650
  diffRender: resolveDiffRender(flags, env.partial, file),
651
+ scrollMode: resolveScrollMode(flags, env.partial, file),
641
652
  syncOutput: resolveSyncOutput(flags, env.partial, file),
642
653
  confirmTools,
643
654
  toolTimeoutMs,
@@ -39,6 +39,17 @@ export declare function isThemeName(v: unknown): v is ThemeName;
39
39
  * and would not match `clampThinkingLevel` / `clampTranscriptWindow`.
40
40
  */
41
41
  export declare function clampTheme(v: unknown, fallback: ThemeName): ThemeName;
42
+ export declare const SCROLL_MODES: readonly ["native", "app"];
43
+ /**
44
+ * How the interactive TUI scrolls (tui-native-terminal-scroll D1).
45
+ *
46
+ * `native` prints settled conversation into the host terminal's own scrollback and
47
+ * keeps only a bounded live region on screen; `app` is the original full-screen
48
+ * alternate-screen layout with application-owned scrolling.
49
+ */
50
+ export type ScrollMode = (typeof SCROLL_MODES)[number];
51
+ export declare const DEFAULT_SCROLL_MODE: ScrollMode;
52
+ export declare function clampScrollMode(v: unknown, fallback: ScrollMode): ScrollMode;
42
53
  export declare const DENSITY_MODES: readonly ["comfortable", "compact"];
43
54
  export type DensityMode = (typeof DENSITY_MODES)[number];
44
55
  export declare function clampDensity(v: unknown, fallback: DensityMode): DensityMode;
@@ -864,6 +875,8 @@ export interface PersistedConfig {
864
875
  maxRenderIntervalMs: number;
865
876
  /** Repaint only the frame rows that changed (tui-input-flicker-fix §5.1). */
866
877
  diffRender: boolean;
878
+ /** Native terminal scrolling vs the full-screen app layout (tui-native-terminal-scroll D1). */
879
+ scrollMode: ScrollMode;
867
880
  /** Wrap each repaint batch in DEC 2026 synchronized update (§5.1). */
868
881
  syncOutput: boolean;
869
882
  confirmTools: boolean;
@@ -1068,6 +1081,20 @@ export interface CliConfig {
1068
1081
  * with no compile error and no runtime error.
1069
1082
  */
1070
1083
  diffRender: boolean;
1084
+ /**
1085
+ * Native terminal scrolling vs the full-screen app layout
1086
+ * (tui-native-terminal-scroll D1).
1087
+ *
1088
+ * IT MUST EXIST HERE AND NOT ONLY IN `PersistedConfig`, for the reason
1089
+ * `diffRender` above records: `runInteractive` reads `config.scrollMode`, where
1090
+ * `config` is a `CliConfig`, so a key added to the persisted shape alone would
1091
+ * round-trip through `config set` and never reach the screen plan.
1092
+ *
1093
+ * OPTIONAL ON THIS SHAPE ONLY so the many hand-built `CliConfig` fixtures in the
1094
+ * test suite stay valid without edits (I-S9); `loadConfig` always sets it and
1095
+ * the one reader (`runInteractive`) falls back to `DEFAULT_SCROLL_MODE`.
1096
+ */
1097
+ scrollMode?: ScrollMode;
1071
1098
  /** Wrap each repaint batch in DEC 2026 synchronized update (§5.1). */
1072
1099
  syncOutput: boolean;
1073
1100
  confirmTools: boolean;
@@ -69,6 +69,14 @@ export function clampTheme(v, fallback) {
69
69
  // ---------------------------------------------------------------------------
70
70
  // Density / hints (v0.4.0)
71
71
  // ---------------------------------------------------------------------------
72
+ export const SCROLL_MODES = ['native', 'app'];
73
+ export const DEFAULT_SCROLL_MODE = 'native';
74
+ export function clampScrollMode(v, fallback) {
75
+ if (typeof v !== 'string')
76
+ return fallback;
77
+ const normalized = v.trim().toLowerCase();
78
+ return normalized === 'native' || normalized === 'app' ? normalized : fallback;
79
+ }
72
80
  export const DENSITY_MODES = ['comfortable', 'compact'];
73
81
  export function clampDensity(v, fallback) {
74
82
  return v === 'comfortable' || v === 'compact' ? v : fallback;
@@ -970,6 +978,7 @@ export const DEFAULT_CONFIG = {
970
978
  renderGovernor: DEFAULT_RENDER_GOVERNOR,
971
979
  maxRenderIntervalMs: DEFAULT_MAX_RENDER_INTERVAL_MS,
972
980
  diffRender: DEFAULT_DIFF_RENDER,
981
+ scrollMode: DEFAULT_SCROLL_MODE,
973
982
  syncOutput: DEFAULT_SYNC_OUTPUT,
974
983
  confirmTools: false,
975
984
  toolTimeoutMs: DEFAULT_TOOL_TIMEOUT_MS,