@aragon-agent/cli 0.6.7 → 0.6.8

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 (63) hide show
  1. package/README.md +72 -43
  2. package/dist/agent/controller.d.ts +10 -6
  3. package/dist/agent/controller.js +35 -48
  4. package/dist/agent/headless.d.ts +2 -1
  5. package/dist/agent/prompt-options.d.ts +15 -0
  6. package/dist/agent/prompt-options.js +1 -0
  7. package/dist/cli.js +83 -53
  8. package/dist/commands/builtins.js +3 -1
  9. package/dist/commands/registry.d.ts +2 -3
  10. package/dist/input/interrupt-gesture.d.ts +18 -0
  11. package/dist/input/interrupt-gesture.js +19 -0
  12. package/dist/input/pointer-router.d.ts +11 -0
  13. package/dist/input/pointer-router.js +14 -0
  14. package/dist/todo/store.d.ts +8 -13
  15. package/dist/todo/store.js +13 -14
  16. package/dist/todo/types.d.ts +2 -1
  17. package/dist/ui/App.d.ts +2 -0
  18. package/dist/ui/App.js +166 -128
  19. package/dist/ui/Composer.d.ts +11 -5
  20. package/dist/ui/Composer.js +7 -11
  21. package/dist/ui/PromptCaret.d.ts +11 -0
  22. package/dist/ui/PromptCaret.js +31 -0
  23. package/dist/ui/PromptInput.d.ts +9 -6
  24. package/dist/ui/PromptInput.js +34 -14
  25. package/dist/ui/StatusBar.d.ts +1 -0
  26. package/dist/ui/StatusBar.js +14 -8
  27. package/dist/ui/Transcript.js +1 -0
  28. package/dist/ui/composer-rows.js +50 -9
  29. package/dist/ui/frame-differ.js +2 -19
  30. package/dist/ui/frame-observer.d.ts +17 -0
  31. package/dist/ui/frame-observer.js +81 -0
  32. package/dist/ui/frame-parser.d.ts +7 -0
  33. package/dist/ui/frame-parser.js +18 -0
  34. package/dist/ui/layout/AppShell.d.ts +7 -52
  35. package/dist/ui/layout/AppShell.js +2 -2
  36. package/dist/ui/layout/ScrollIndicator.d.ts +4 -3
  37. package/dist/ui/layout/ScrollIndicator.js +4 -4
  38. package/dist/ui/layout/ScrollViewport.d.ts +13 -66
  39. package/dist/ui/layout/ScrollViewport.js +183 -185
  40. package/dist/ui/layout/budget.d.ts +3 -51
  41. package/dist/ui/layout/budget.js +4 -53
  42. package/dist/ui/layout/document-layout.d.ts +13 -0
  43. package/dist/ui/layout/document-layout.js +11 -0
  44. package/dist/ui/layout/follow-state.d.ts +1 -0
  45. package/dist/ui/layout/follow-state.js +1 -3
  46. package/dist/ui/layout/scroll-indicator.d.ts +1 -1
  47. package/dist/ui/layout/scroll-indicator.js +3 -2
  48. package/dist/ui/layout/scrollbar-geometry.d.ts +17 -0
  49. package/dist/ui/layout/scrollbar-geometry.js +13 -0
  50. package/dist/ui/layout/todo-layout.d.ts +1 -0
  51. package/dist/ui/layout/todo-layout.js +3 -2
  52. package/dist/ui/layout/viewport-geometry.d.ts +1 -0
  53. package/dist/ui/layout/viewport-geometry.js +1 -0
  54. package/dist/ui/layout/virtual-window.d.ts +1 -0
  55. package/dist/ui/layout/virtual-window.js +1 -1
  56. package/dist/ui/overlays/HelpOverlay.js +5 -2
  57. package/dist/ui/screen.d.ts +1 -1
  58. package/dist/ui/screen.js +4 -6
  59. package/dist/ui/scrollbar-controller.d.ts +30 -0
  60. package/dist/ui/scrollbar-controller.js +124 -0
  61. package/dist/ui/use-wheel-routing.d.ts +2 -0
  62. package/dist/ui/use-wheel-routing.js +6 -0
  63. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -45,6 +45,9 @@ 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';
@@ -476,13 +479,14 @@ function runInteractive(flags, extras = {}) {
476
479
  const disposeStdinFilter = () => filter?.dispose();
477
480
  // --- Drag-select (tui-selection-and-scroll-follow §4.4). ------------------
478
481
  //
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.
482
+ // Selection owns its controller, decoration and notice. Button motion follows
483
+ // mouseOn independently so --no-mouse-select still permits scrollbar dragging.
485
484
  const wantSelect = mouseOn && config.mouseSelect;
485
+ let mouseCaptured = mouseOn;
486
+ const scrollbar = createScrollbarBridge(() => mouseCaptured);
487
+ const pointerRouter = mouseOn && filter ? createPointerRouter({
488
+ source: filter.source, handle: (event) => scrollbar.controller?.handle(event) ?? false,
489
+ }) : null;
486
490
  const selectionBridge = {
487
491
  controller: null,
488
492
  onCopied: null,
@@ -535,7 +539,13 @@ function runInteractive(flags, extras = {}) {
535
539
  // than printing zeroes that read like a writer doing nothing (§5.4).
536
540
  if (frameWriter)
537
541
  setFrameStatsProvider(frameWriter.stats);
542
+ const frameObserver = mode === 'fullscreen' ? createFrameObserver({
543
+ stdout: frameWriter?.stdout ?? process.stdout, terminal: process.stdout, scrollbar,
544
+ }) : null;
538
545
  const disposeFrameWriter = () => {
546
+ frameObserver?.dispose();
547
+ pointerRouter?.dispose();
548
+ scrollbar.controller?.dispose();
539
549
  if (!frameWriter)
540
550
  return;
541
551
  setFrameStatsProvider(null);
@@ -574,7 +584,7 @@ function runInteractive(flags, extras = {}) {
574
584
  // keep their terminal's own selection.
575
585
  screen = enterAltScreen(process.stdout, {
576
586
  mouse: mouseOn,
577
- motion: wantSelect,
587
+ motion: mouseOn,
578
588
  bracketedPaste: pasteOn && mode === 'fullscreen',
579
589
  });
580
590
  const restore = () => screen?.restore();
@@ -637,7 +647,7 @@ function runInteractive(flags, extras = {}) {
637
647
  // attempted. Naming the mechanism honestly is the entire reason that return
638
648
  // value exists (R-7), and a door that lies is worse than no door.
639
649
  const writeForeign = frameWriter
640
- ? (text) => frameWriter.writeForeign(text)
650
+ ? (text) => { frameObserver?.invalidate(); frameWriter.writeForeign(text); }
641
651
  : process.stdout.isTTY
642
652
  ? (text) => {
643
653
  try {
@@ -659,10 +669,9 @@ function runInteractive(flags, extras = {}) {
659
669
  // than inside `screen.ts`'s closure because `App` needs to READ it and the
660
670
  // handle's own flag is private (it has to be — `restore()` reads it, and
661
671
  // exposing a setter would be a second way to get the two out of step).
662
- let mouseCaptured = mouseOn;
663
672
  if (wantSelect && filter) {
664
673
  selectionBridge.controller = createSelectionController({
665
- source: filter.source,
674
+ source: pointerRouter?.selectionSource ?? filter.source,
666
675
  // The frame writer is absent under `--no-diff-render`, where there is no
667
676
  // cache to repaint from at all; the controller then falls back to asking
668
677
  // `App` for a React redraw on every drag flush, which is slower and
@@ -702,11 +711,14 @@ function runInteractive(flags, extras = {}) {
702
711
  const terminalBridge = mode === 'fullscreen' || writeForeign
703
712
  ? {
704
713
  mouseSelect: wantSelect,
714
+ ...(mode === 'fullscreen' ? { scrollbar } : {}),
705
715
  ...(mouseOn
706
716
  ? {
707
717
  setMouseCapture: (on) => {
708
718
  screen?.setMouseCapture(on);
709
719
  mouseCaptured = on;
720
+ if (!on)
721
+ scrollbar.controller?.cancel();
710
722
  // A released mouse cannot be dragging, and a selection left
711
723
  // painted over a screen the terminal is now selecting on its
712
724
  // own would be two highlights claiming the same rows.
@@ -724,41 +736,67 @@ function runInteractive(flags, extras = {}) {
724
736
  : undefined;
725
737
  const updateBridge = { service: null, onAttach: null };
726
738
  let updateService = null;
739
+ let interactiveClosed = false;
740
+ const failInteractive = (error) => {
741
+ // Ink consumes render exceptions; retain failure even if a disposer throws.
742
+ process.exitCode = 1;
743
+ interactiveClosed = true;
744
+ logger.error('cli', 'render_failed', { error: String(error) });
745
+ for (const cleanup of [
746
+ () => screen?.restore(), disposeSelection, disposeStdinFilter, disposeFrameWriter,
747
+ () => controller.abort(), () => controller.dispose(), () => updateService?.dispose(),
748
+ () => { process.stdin.pause(); process.stdin.unref?.(); },
749
+ ]) {
750
+ try {
751
+ cleanup();
752
+ }
753
+ catch (cleanupError) {
754
+ logger.error('cli', 'render_cleanup_failed', { error: String(cleanupError) });
755
+ }
756
+ }
757
+ };
727
758
  const updateEligible = !!process.stdout.isTTY &&
728
759
  !!process.stdin.isTTY &&
729
760
  !process.env.CI &&
730
761
  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
- });
762
+ let instance;
763
+ 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,
770
+ // Present ONLY when this console swallows `CSI Z` and mouse reports.
771
+ // Passed in rather than probed inside `App` so that every test renders
772
+ // the same tree on every machine — reading `process.versions.node` from
773
+ // a component would make the notice appear or vanish according to which
774
+ // Node the developer happened to have installed.
775
+ vtInputWarning: vtInputSupported ? undefined : { nodeVersion: process.versions.node }, updateBridge: updateEligible ? updateBridge : undefined,
776
+ // Absent when nothing can refuse a paste, so a `--no-paste` tree is
777
+ // byte-identical to a build without this feature.
778
+ pasteBridge: pasteOn ? pasteBridge : undefined, terminal: terminalBridge }),
779
+ // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
780
+ // straight to stdout and permanently shifts the fixed frame's accounting.
781
+ {
782
+ exitOnCtrlC: false,
783
+ patchConsole: mode === 'inline',
784
+ // I-5: with mouse support off the REAL stdin is handed over unwrapped, so
785
+ // every existing path is provably unchanged.
786
+ 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 } : {}),
794
+ });
795
+ }
796
+ catch (error) {
797
+ failInteractive(error);
798
+ throw error;
799
+ }
762
800
  // H1's primary disarm, and this is the earliest moment at which "this build
763
801
  // STARTS" is proven: the module graph loaded, config resolved, Ink mounted
764
802
  // (cli-auto-update-hardening section 5.1.4). It is what makes a long session
@@ -780,6 +818,8 @@ function runInteractive(flags, extras = {}) {
780
818
  // must degrade to "no updater", never to a crashed TUI.
781
819
  void import('./update/service.js')
782
820
  .then(({ UpdateService }) => {
821
+ if (interactiveClosed)
822
+ return;
783
823
  const service = new UpdateService({ config: config.update, currentVersion: VERSION });
784
824
  updateService = service;
785
825
  updateBridge.service = service;
@@ -810,6 +850,7 @@ function runInteractive(flags, extras = {}) {
810
850
  void instance
811
851
  .waitUntilExit()
812
852
  .then(() => {
853
+ interactiveClosed = true;
813
854
  screen?.restore();
814
855
  // BEFORE `disposeStdinFilter()`, which clears the listener set it subscribed to,
815
856
  // and after `restore()` for the reason that call records: the terminal must
@@ -838,18 +879,7 @@ function runInteractive(flags, extras = {}) {
838
879
  updateService?.dispose();
839
880
  replayTranscript();
840
881
  })
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
- });
882
+ .catch(failInteractive);
853
883
  }
854
884
  async function runOneShot(flags, prompt, quiet) {
855
885
  // Headless: no App is ever rendered, so there is no human to approve a skill
@@ -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 ` +
@@ -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).
@@ -0,0 +1,18 @@
1
+ /** Confirmation window for two independent decoded Escape events. */
2
+ export declare const INTERRUPT_CONFIRM_MS = 1500;
3
+ export type InterruptGestureState = {
4
+ phase: 'ready';
5
+ } | {
6
+ phase: 'armed';
7
+ armedAt: number;
8
+ } | {
9
+ phase: 'stopping';
10
+ };
11
+ export interface InterruptGestureResult {
12
+ state: InterruptGestureState;
13
+ action: 'hint' | 'abort' | 'force-stop';
14
+ }
15
+ /** Advance using a monotonic clock; the caller owns the current run's lifetime. */
16
+ export declare function advanceInterruptGesture(state: InterruptGestureState, now: number): InterruptGestureResult;
17
+ /** Other input cancels confirmation, but cannot undo an already requested stop. */
18
+ export declare function cancelInterruptConfirmation(state: InterruptGestureState): InterruptGestureState;
@@ -0,0 +1,19 @@
1
+ /** Confirmation window for two independent decoded Escape events. */
2
+ export const INTERRUPT_CONFIRM_MS = 1500;
3
+ /** Advance using a monotonic clock; the caller owns the current run's lifetime. */
4
+ export function advanceInterruptGesture(state, now) {
5
+ if (state.phase === 'stopping') {
6
+ return { state: { phase: 'ready' }, action: 'force-stop' };
7
+ }
8
+ if (state.phase === 'armed') {
9
+ const elapsed = now - state.armedAt;
10
+ if (elapsed >= 0 && elapsed <= INTERRUPT_CONFIRM_MS) {
11
+ return { state: { phase: 'stopping' }, action: 'abort' };
12
+ }
13
+ }
14
+ return { state: { phase: 'armed', armedAt: now }, action: 'hint' };
15
+ }
16
+ /** Other input cancels confirmation, but cannot undo an already requested stop. */
17
+ export function cancelInterruptConfirmation(state) {
18
+ return state.phase === 'armed' ? { phase: 'ready' } : state;
19
+ }
@@ -0,0 +1,11 @@
1
+ import type { MouseEvent } from './mouse-events.js';
2
+ import type { MouseSource } from './stdin-filter.js';
3
+ export interface PointerRouter {
4
+ selectionSource: MouseSource;
5
+ dispose(): void;
6
+ }
7
+ /** Deliver each button gesture to the scrollbar before the selection source. */
8
+ export declare function createPointerRouter(options: {
9
+ source: MouseSource;
10
+ handle(event: MouseEvent): boolean;
11
+ }): PointerRouter;
@@ -0,0 +1,14 @@
1
+ /** Deliver each button gesture to the scrollbar before the selection source. */
2
+ export function createPointerRouter(options) {
3
+ const listeners = new Set();
4
+ const unsubscribe = options.source.subscribe((event) => {
5
+ if (event.kind !== 'wheel' && options.handle(event))
6
+ return;
7
+ for (const listener of listeners)
8
+ listener(event);
9
+ });
10
+ return { selectionSource: { subscribe: (listener) => {
11
+ listeners.add(listener);
12
+ return () => { listeners.delete(listener); };
13
+ } }, dispose: () => { unsubscribe(); listeners.clear(); } };
14
+ }
@@ -9,15 +9,9 @@
9
9
  * is why `AgentController` can own this object as unconditionally as it owns
10
10
  * `TeamRuntime`.
11
11
  *
12
- * INVARIANT I-2 — the list is a PROJECTION OF WHAT THE MODEL BELIEVES, and
13
- * nothing that does not tell the model may change it — EXCEPT AN EXPLICIT USER
14
- * INSTRUCTION, which is the rule's standing exception and is marked
15
- * `TodoClearReason = 'user'`. Two commands come through that door: `/todo clear`
16
- * and `/clear`. Neither tells the model anything, and that is fine, because what
17
- * they leave behind is "panel absent, model still remembers" - the direction I-9
18
- * below calls safe, and one the model's next full-replacement write heals on its
19
- * own. `/reset` (reason `'reset'`) remains the only clear caused by the belief
20
- * itself being destroyed, because it clears `messages`.
12
+ * The list projects the current plan. Explicit clear/reset and a new TUI task
13
+ * may remove that projection while the model still remembers the old plan.
14
+ * Continuations preserve it; legacy callers retain completion/staleness rules.
21
15
  *
22
16
  * Every write is a FULL REPLACEMENT and every clear is a FULL REMOVAL - there is
23
17
  * no partial update anywhere, which is what makes I-9 ("the panel is never a
@@ -73,11 +67,12 @@ export declare class TodoStore {
73
67
  * asymmetry for free, which is the behaviour you want: a steer must not age
74
68
  * out the plan it is steering.
75
69
  *
76
- * A FULLY-COMPLETED LIST IS DROPPED HERE rather than at `agent_end`. Ending
77
- * the run is exactly when the user wants to see `7/7 done`; wiping it there
78
- * would replace a moment of feedback with a blank column (D-6).
70
+ * A new TUI task drops the old plan here, while a continuation preserves it.
71
+ * With no policy, legacy callers retain completed/stale-plan expiration.
72
+ * Clearing at `agent_end` would erase the final progress before the user can
73
+ * inspect it and remove the plan that automatic continuation still needs.
79
74
  */
80
- beginUserTurn(): void;
75
+ beginUserTurn(policy?: 'new-task' | 'continue'): void;
81
76
  /**
82
77
  * Report a refusal that never reached `write()` (§3.3 steps 1 and 2).
83
78
  *
@@ -9,15 +9,9 @@
9
9
  * is why `AgentController` can own this object as unconditionally as it owns
10
10
  * `TeamRuntime`.
11
11
  *
12
- * INVARIANT I-2 — the list is a PROJECTION OF WHAT THE MODEL BELIEVES, and
13
- * nothing that does not tell the model may change it — EXCEPT AN EXPLICIT USER
14
- * INSTRUCTION, which is the rule's standing exception and is marked
15
- * `TodoClearReason = 'user'`. Two commands come through that door: `/todo clear`
16
- * and `/clear`. Neither tells the model anything, and that is fine, because what
17
- * they leave behind is "panel absent, model still remembers" - the direction I-9
18
- * below calls safe, and one the model's next full-replacement write heals on its
19
- * own. `/reset` (reason `'reset'`) remains the only clear caused by the belief
20
- * itself being destroyed, because it clears `messages`.
12
+ * The list projects the current plan. Explicit clear/reset and a new TUI task
13
+ * may remove that projection while the model still remembers the old plan.
14
+ * Continuations preserve it; legacy callers retain completion/staleness rules.
21
15
  *
22
16
  * Every write is a FULL REPLACEMENT and every clear is a FULL REMOVAL - there is
23
17
  * no partial update anywhere, which is what makes I-9 ("the panel is never a
@@ -137,13 +131,18 @@ export class TodoStore {
137
131
  * asymmetry for free, which is the behaviour you want: a steer must not age
138
132
  * out the plan it is steering.
139
133
  *
140
- * A FULLY-COMPLETED LIST IS DROPPED HERE rather than at `agent_end`. Ending
141
- * the run is exactly when the user wants to see `7/7 done`; wiping it there
142
- * would replace a moment of feedback with a blank column (D-6).
134
+ * A new TUI task drops the old plan here, while a continuation preserves it.
135
+ * With no policy, legacy callers retain completed/stale-plan expiration.
136
+ * Clearing at `agent_end` would erase the final progress before the user can
137
+ * inspect it and remove the plan that automatic continuation still needs.
143
138
  */
144
- beginUserTurn() {
145
- if (this.items.length === 0)
139
+ beginUserTurn(policy) {
140
+ if (this.items.length === 0 || policy === 'continue')
141
+ return;
142
+ if (policy === 'new-task') {
143
+ this.clear('turn');
146
144
  return;
145
+ }
147
146
  if (this.items.every((item) => item.status === 'completed')) {
148
147
  this.clear('turn');
149
148
  return;
@@ -45,7 +45,8 @@ export interface TodoSnapshot {
45
45
  * `user` - `/todo clear`, or `/clear`. Both are an explicit user instruction,
46
46
  * which is I-2's standing exception; neither touches `messages`.
47
47
  * `reset` - `/reset`, or a `/resume` whose file carried no list (§3.13).
48
- * `turn` - every item was completed when the next user turn started.
48
+ * `turn` - a new TUI task started, or a legacy caller began a turn after
49
+ * every item was completed.
49
50
  * `stale` - unfinished, and unwritten for more than `TODO_LIMITS.staleTurns`
50
51
  * consecutive turns (§3.2 / P1-7).
51
52
  */
package/dist/ui/App.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * input / toast stack / status bar / overlays.
6
6
  */
7
7
  import React from 'react';
8
+ import type { ScrollbarBridge } from './scrollbar-controller.js';
8
9
  import type { AgentController } from '../agent/controller.js';
9
10
  import type { ConfirmRequest } from '../tools/index.js';
10
11
  import { type Overlay, type ToastLevel } from '../agent/reducer.js';
@@ -109,6 +110,7 @@ export interface AppProps {
109
110
  * mounted. The effect below assigns both and clears them on unmount.
110
111
  */
111
112
  export interface TerminalBridge {
113
+ scrollbar?: ScrollbarBridge;
112
114
  /** Whether drag-select is on for this session (`mouseSelect` + a filter). */
113
115
  mouseSelect: boolean;
114
116
  /** Release the mouse to the terminal, or take it back (`/mouse`, G3). */