@aragon-agent/cli 0.6.2 → 0.6.3

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 (118) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +202 -0
  3. package/dist/agent/controller.d.ts +74 -1
  4. package/dist/agent/controller.js +200 -1
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +120 -0
  8. package/dist/agent/reducer.js +201 -2
  9. package/dist/cli.js +224 -5
  10. package/dist/commands/builtins.js +73 -23
  11. package/dist/commands/registry.d.ts +27 -0
  12. package/dist/compaction/archive.d.ts +175 -0
  13. package/dist/compaction/archive.js +267 -0
  14. package/dist/compaction/child.d.ts +97 -0
  15. package/dist/compaction/child.js +113 -0
  16. package/dist/compaction/command.d.ts +41 -0
  17. package/dist/compaction/command.js +342 -0
  18. package/dist/compaction/compactor.d.ts +268 -0
  19. package/dist/compaction/compactor.js +689 -0
  20. package/dist/compaction/digest.d.ts +62 -0
  21. package/dist/compaction/digest.js +164 -0
  22. package/dist/compaction/limits.d.ts +165 -0
  23. package/dist/compaction/limits.js +172 -0
  24. package/dist/compaction/pressure.d.ts +135 -0
  25. package/dist/compaction/pressure.js +167 -0
  26. package/dist/compaction/summarize-call.d.ts +70 -0
  27. package/dist/compaction/summarize-call.js +86 -0
  28. package/dist/compaction/summary-prompt.d.ts +85 -0
  29. package/dist/compaction/summary-prompt.js +197 -0
  30. package/dist/compaction/tail-budget.d.ts +52 -0
  31. package/dist/compaction/tail-budget.js +67 -0
  32. package/dist/compaction/types.d.ts +179 -0
  33. package/dist/compaction/types.js +22 -0
  34. package/dist/compaction/wiring.d.ts +255 -0
  35. package/dist/compaction/wiring.js +556 -0
  36. package/dist/config/app-paths.d.ts +14 -0
  37. package/dist/config/app-paths.js +16 -0
  38. package/dist/config/cli-commands.d.ts +41 -0
  39. package/dist/config/cli-commands.js +77 -1
  40. package/dist/config/env.d.ts +4 -1
  41. package/dist/config/env.js +92 -2
  42. package/dist/config/load.d.ts +31 -0
  43. package/dist/config/load.js +63 -1
  44. package/dist/config/schema.d.ts +159 -0
  45. package/dist/config/schema.js +137 -0
  46. package/dist/config/store.js +17 -1
  47. package/dist/config/ui-state.d.ts +28 -1
  48. package/dist/config/ui-state.js +9 -0
  49. package/dist/exec/events.d.ts +50 -1
  50. package/dist/exec/runner.js +42 -0
  51. package/dist/input/mouse-events.d.ts +31 -5
  52. package/dist/input/mouse-events.js +86 -27
  53. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  54. package/dist/logging/install.d.ts +51 -1
  55. package/dist/logging/install.js +28 -0
  56. package/dist/logging/logger.d.ts +1 -1
  57. package/dist/session/persist.js +19 -0
  58. package/dist/team/report.js +7 -1
  59. package/dist/team/runtime.d.ts +10 -0
  60. package/dist/team/runtime.js +7 -0
  61. package/dist/team/subagent.d.ts +31 -1
  62. package/dist/team/subagent.js +42 -15
  63. package/dist/team/types.d.ts +8 -0
  64. package/dist/ui/ActivityLine.d.ts +19 -1
  65. package/dist/ui/ActivityLine.js +10 -2
  66. package/dist/ui/App.d.ts +48 -1
  67. package/dist/ui/App.js +294 -13
  68. package/dist/ui/Composer.d.ts +13 -1
  69. package/dist/ui/Composer.js +2 -2
  70. package/dist/ui/PromptInput.d.ts +34 -1
  71. package/dist/ui/PromptInput.js +54 -3
  72. package/dist/ui/StatusBar.d.ts +40 -0
  73. package/dist/ui/StatusBar.js +13 -4
  74. package/dist/ui/TeamPanel.js +7 -1
  75. package/dist/ui/Transcript.d.ts +21 -1
  76. package/dist/ui/Transcript.js +32 -2
  77. package/dist/ui/clipboard.d.ts +52 -0
  78. package/dist/ui/clipboard.js +91 -0
  79. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  80. package/dist/ui/entries/CompactionCard.js +158 -0
  81. package/dist/ui/frame-differ.d.ts +46 -0
  82. package/dist/ui/frame-differ.js +94 -10
  83. package/dist/ui/gauge.d.ts +16 -1
  84. package/dist/ui/gauge.js +25 -6
  85. package/dist/ui/glyphs.d.ts +11 -0
  86. package/dist/ui/glyphs.js +2 -0
  87. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  88. package/dist/ui/layout/ScrollViewport.js +128 -21
  89. package/dist/ui/layout/budget.d.ts +10 -3
  90. package/dist/ui/layout/budget.js +10 -3
  91. package/dist/ui/layout/follow-state.d.ts +137 -0
  92. package/dist/ui/layout/follow-state.js +150 -0
  93. package/dist/ui/layout/virtual-window.d.ts +11 -0
  94. package/dist/ui/layout/virtual-window.js +34 -0
  95. package/dist/ui/overlays/HelpOverlay.js +6 -1
  96. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  97. package/dist/ui/overlays/SettingsScreen.js +60 -1
  98. package/dist/ui/palettes.d.ts +16 -0
  99. package/dist/ui/palettes.js +6 -0
  100. package/dist/ui/screen.d.ts +22 -0
  101. package/dist/ui/screen.js +70 -8
  102. package/dist/ui/selection/highlight.d.ts +39 -0
  103. package/dist/ui/selection/highlight.js +64 -0
  104. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  105. package/dist/ui/selection/screen-mirror.js +35 -0
  106. package/dist/ui/selection/selection-controller.d.ts +88 -0
  107. package/dist/ui/selection/selection-controller.js +282 -0
  108. package/dist/ui/selection/selection.d.ts +90 -0
  109. package/dist/ui/selection/selection.js +223 -0
  110. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  111. package/dist/ui/stdout-frame-writer.js +15 -0
  112. package/dist/ui/theme.d.ts +11 -0
  113. package/dist/ui/theme.js +2 -0
  114. package/dist/ui/transcript-text.js +23 -0
  115. package/dist/ui/use-startup-notices.d.ts +27 -1
  116. package/dist/ui/use-startup-notices.js +43 -11
  117. package/dist/ui/use-wheel-routing.js +8 -0
  118. package/package.json +4 -2
@@ -169,6 +169,42 @@ export const DEFAULT_DIFF_RENDER = true;
169
169
  */
170
170
  export const DEFAULT_SYNC_OUTPUT = true;
171
171
  // ---------------------------------------------------------------------------
172
+ // Selection + scroll follow (tui-selection-and-scroll-follow §6.1)
173
+ // ---------------------------------------------------------------------------
174
+ /**
175
+ * Drag-select + copy, and — because §4.4.1 gates `\x1b[?1002h` on exactly this
176
+ * value — whether motion reporting is requested from the terminal at all.
177
+ *
178
+ * That gating is what makes `false` BYTE-IDENTICAL to a pre-feature session
179
+ * rather than merely equivalent (AC-8 / D-15). Ignored when `mouse` is false,
180
+ * since there is no capture to select with.
181
+ */
182
+ export const DEFAULT_MOUSE_SELECT = true;
183
+ /**
184
+ * Idle delay before a paused viewport returns to the newest line, in ms.
185
+ *
186
+ * Five seconds is chosen to be LONGER than a glance and SHORTER than a read.
187
+ * `0` disables auto-resume entirely, and it is a real value rather than a
188
+ * sentinel-shaped default: `shouldArmResume` refuses on it before any timer is
189
+ * created (AC-9), so the revert for Rule B is one config key.
190
+ *
191
+ * NO CLI FLAG (§6.1). It is a comfort preference, set once
192
+ * (`aragon config set scrollResumeMs 8000`) and then forgotten — the same
193
+ * argument this file already makes for `historyEnabled`. Adding a flag for every
194
+ * scalar is how a CLI ends up with sixty of them.
195
+ */
196
+ export const DEFAULT_SCROLL_RESUME_MS = 5000;
197
+ const SCROLL_RESUME_RANGE = { min: 0, max: 120_000 };
198
+ /**
199
+ * `clampIntAllowingZero`, and the choice is load-bearing: `clampInt` delegates to
200
+ * `coercePositiveInt`, which returns the FALLBACK for `n <= 0` — so `0` would
201
+ * silently become 5000 and the documented off switch would not be an off switch.
202
+ * Exactly the trap `retry.maxRetries` needed this helper for.
203
+ */
204
+ export function clampScrollResumeMs(v, fallback) {
205
+ return clampIntAllowingZero(v, fallback, SCROLL_RESUME_RANGE);
206
+ }
207
+ // ---------------------------------------------------------------------------
172
208
  // Plan mode (plan-mode §4.4)
173
209
  //
174
210
  // THREE FLAT KEYS, not a nested `planMode` object. `store.ts` documents that its
@@ -669,6 +705,99 @@ function isRegistryUrl(raw) {
669
705
  return false;
670
706
  }
671
707
  }
708
+ export const DEFAULT_COMPACTION_CONFIG = {
709
+ enabled: true,
710
+ threshold: 0.9,
711
+ warnThreshold: 0.75,
712
+ keepRecentTurns: 4,
713
+ useFastTier: true,
714
+ onFailure: 'truncate',
715
+ subagents: true,
716
+ archive: true,
717
+ };
718
+ const THRESHOLD_RANGE = { min: 0.5, max: 0.95 };
719
+ const WARN_THRESHOLD_FLOOR = 0.4;
720
+ /** The gap the warn mark must stay below the trigger by. */
721
+ const WARN_THRESHOLD_GAP = 0.05;
722
+ const KEEP_RECENT_TURNS_RANGE = { min: 1, max: 20 };
723
+ const COMPACTION_FAILURE_MODES = ['truncate', 'stop'];
724
+ /**
725
+ * Round a ratio to two decimals — the precision a percentage actually has.
726
+ *
727
+ * BINARY FLOATING POINT MAKES `0.95 - 0.05` EQUAL `0.8999999999999999`, and both
728
+ * numbers here are produced by subtraction (`threshold - WARN_THRESHOLD_GAP`) and
729
+ * by division (`parseThresholdInput` turning `90` into `90/100`). Without this,
730
+ * `aragon config list` shows a seventeen-digit value for a setting the user typed
731
+ * as `90%`, and `config.json` records one — which looks like corruption and
732
+ * invites a hand-edit that the clamp then has to defend against.
733
+ *
734
+ * Two decimals is exact for every value the range can hold: the clamp is
735
+ * `[0.5, 0.95]` and the UI reads it as a whole percentage.
736
+ */
737
+ function ratio(value) {
738
+ return Math.round(value * 100) / 100;
739
+ }
740
+ function failureMode(value, fallback) {
741
+ return typeof value === 'string' && COMPACTION_FAILURE_MODES.includes(value)
742
+ ? value
743
+ : fallback;
744
+ }
745
+ /**
746
+ * THE single gate for every compaction-config read AND write, mirroring the six
747
+ * clamps above for the reason all of them record: hardening only the read path
748
+ * leaves a bad value on disk that reverts to the default on every launch, which
749
+ * presents to the user as "my setting won't stick".
750
+ *
751
+ * THE ORDER IS FIXED AND IT MATTERS (§4.2 / P2-5). `threshold` is clamped to
752
+ * `[0.5, 0.95]` FIRST, then `warnThreshold` to
753
+ * `[0.4, clampedThreshold - 0.05]`. The other order lets a file holding
754
+ * `{threshold: 0.6, warnThreshold: 0.9}` produce a warn mark ABOVE the trigger —
755
+ * a gauge that turns amber after it has already gone red. Both bounds of the
756
+ * second range stay reachable at the extremes: `threshold` floored at `0.5`
757
+ * gives `warnThreshold` a range of `[0.4, 0.45]`.
758
+ */
759
+ export function clampCompactionConfig(raw) {
760
+ const src = (raw && typeof raw === 'object' ? raw : {});
761
+ const threshold = ratio(clampNumber(src.threshold, DEFAULT_COMPACTION_CONFIG.threshold, THRESHOLD_RANGE));
762
+ const warnCeiling = ratio(threshold - WARN_THRESHOLD_GAP);
763
+ return {
764
+ enabled: bool(src.enabled, DEFAULT_COMPACTION_CONFIG.enabled),
765
+ threshold,
766
+ warnThreshold: ratio(clampNumber(src.warnThreshold, DEFAULT_COMPACTION_CONFIG.warnThreshold, {
767
+ min: Math.min(WARN_THRESHOLD_FLOOR, warnCeiling),
768
+ max: warnCeiling,
769
+ })),
770
+ keepRecentTurns: clampInt(src.keepRecentTurns, DEFAULT_COMPACTION_CONFIG.keepRecentTurns, KEEP_RECENT_TURNS_RANGE),
771
+ useFastTier: bool(src.useFastTier, DEFAULT_COMPACTION_CONFIG.useFastTier),
772
+ // CLAMPED TO THE DEFAULT, NEVER REJECTED — the discipline every other key in
773
+ // this file follows. `ARAGON_COMPACTION_ON_FAILURE=garbage` resolves to
774
+ // `truncate` and throws nothing.
775
+ onFailure: failureMode(src.onFailure, DEFAULT_COMPACTION_CONFIG.onFailure),
776
+ subagents: bool(src.subagents, DEFAULT_COMPACTION_CONFIG.subagents),
777
+ archive: bool(src.archive, DEFAULT_COMPACTION_CONFIG.archive),
778
+ };
779
+ }
780
+ /**
781
+ * Parse `0.9`, `90%` or `90` into a ratio, or `null` when it is neither.
782
+ *
783
+ * `null` RATHER THAN A CLAMPED DEFAULT, because this is a COMMAND argument and
784
+ * not a config value: `/compact threshold banana` must say so rather than
785
+ * silently setting 0.9 and reporting success.
786
+ */
787
+ export function parseThresholdInput(raw) {
788
+ const trimmed = raw.trim().replace(/%$/, '');
789
+ if (trimmed.length === 0)
790
+ return null;
791
+ const n = Number.parseFloat(trimmed);
792
+ if (!Number.isFinite(n))
793
+ return null;
794
+ // `90` means 90 %, `0.9` means 0.9. The ambiguity is only at `1`, which is
795
+ // above the clamp ceiling either way.
796
+ const ratio = n > 1 ? n / 100 : n;
797
+ if (ratio <= 0 || ratio > 1)
798
+ return null;
799
+ return ratio;
800
+ }
672
801
  export const DEFAULT_CONFIG = {
673
802
  version: CONFIG_VERSION,
674
803
  provider: 'anthropic',
@@ -703,6 +832,8 @@ export const DEFAULT_CONFIG = {
703
832
  density: 'comfortable',
704
833
  hints: true,
705
834
  mouse: true,
835
+ mouseSelect: DEFAULT_MOUSE_SELECT,
836
+ scrollResumeMs: DEFAULT_SCROLL_RESUME_MS,
706
837
  planModeDefault: false,
707
838
  planModeMaxAskRounds: DEFAULT_PLAN_MAX_ASK_ROUNDS,
708
839
  planModeHumanTimeoutMs: DEFAULT_PLAN_HUMAN_TIMEOUT_MS,
@@ -728,6 +859,12 @@ export const DEFAULT_CONFIG = {
728
859
  // (`cfg.update.mode !== 'off'`) would read TRUE and the feature would be on
729
860
  // for a reason nobody could point at.
730
861
  update: DEFAULT_UPDATE_CONFIG,
862
+ // And one section later again, for the fourth time. Without this line a user
863
+ // whose `config.json` predates compaction resolves `config.compaction.enabled`
864
+ // to `undefined`, which is FALSY — so a feature that is `true` by default would
865
+ // arrive OFF for every existing user, which is the one direction this
866
+ // particular default must never fail in.
867
+ compaction: DEFAULT_COMPACTION_CONFIG,
731
868
  };
732
869
  /**
733
870
  * Runtime state that used to be stored in `config.json` by mistake
@@ -14,7 +14,7 @@ import { dirname } from 'node:path';
14
14
  import process from 'node:process';
15
15
  import { getConfigPath, getSessionsDir } from './app-paths.js';
16
16
  import { registerSecretsFrom } from '../logging/secret-registry.js';
17
- import { CONFIG_VERSION, DEFAULT_CONFIG, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
17
+ import { CONFIG_VERSION, DEFAULT_CONFIG, clampCompactionConfig, clampFastConfig, clampLogConfig, clampMaxTokens, clampRetryConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampUpdateConfig, isAutoToken, stripLegacyStateKeys, } from './schema.js';
18
18
  /**
19
19
  * Normalize `maxTokens` for both directions of the config file.
20
20
  *
@@ -139,6 +139,15 @@ export function loadPersistedConfig() {
139
139
  // predates auto-update, which is all of them. Without it `config.update.mode`
140
140
  // reads `undefined`, and the §3.8 gate tests `!== 'off'`.
141
141
  update: clampUpdateConfig({ ...DEFAULT_CONFIG.update, ...(partial.update ?? {}) }),
142
+ // `compaction` is the ninth. Same one-level rule, same merge — and the READ
143
+ // half supplies a whole section to every user whose `config.json` predates
144
+ // this feature, which is all of them. Without it `config.compaction.enabled`
145
+ // reads `undefined`, which is FALSY, so a feature that is `true` by default
146
+ // would arrive off for every existing user.
147
+ compaction: clampCompactionConfig({
148
+ ...DEFAULT_CONFIG.compaction,
149
+ ...(partial.compaction ?? {}),
150
+ }),
142
151
  };
143
152
  }
144
153
  /**
@@ -208,6 +217,13 @@ export function updatePersistedConfig(patch) {
208
217
  // enterprise mirror the user configured last month is gone with no error
209
218
  // anywhere. AC-20 is the assertion that proves both halves exist.
210
219
  update: clampUpdateConfig({ ...current.update, ...(patch.update ?? {}) }),
220
+ // The ninth section, and the fourth time this file records the same trap.
221
+ // `/compact threshold 0.8` sends `{ compaction: { threshold: 0.8 } }`;
222
+ // without this line the shallow top-level spread REPLACES the section,
223
+ // `enabled` is written absent, and a user who deliberately turned compaction
224
+ // OFF finds it back on tomorrow — a feature that spends money on their behalf
225
+ // re-enabling itself because they nudged a threshold.
226
+ compaction: clampCompactionConfig({ ...current.compaction, ...(patch.compaction ?? {}) }),
211
227
  };
212
228
  writeConfigFile(merged);
213
229
  return merged;
@@ -32,8 +32,32 @@ export interface UiState {
32
32
  schema: number;
33
33
  /** Lifetime submit count; drives the composer hint fade (`HINT_FADE_AFTER`). */
34
34
  submitCount: number;
35
- /** Whether the one-shot "hold Shift to select text" notice has been shown. */
35
+ /**
36
+ * Whether the one-shot "hold Shift to select text" notice has been shown.
37
+ *
38
+ * DELIBERATELY NOT READ BY THE LIVE GATE ANY MORE (tui-selection-and-scroll-
39
+ * follow P1-8). It is kept in the shape so `importLegacyUiState` still has
40
+ * somewhere to put a migrated value and so a file written by 0.6.2 round-trips
41
+ * unchanged; `mouseNoticeVersion` below is what the notice now consults.
42
+ */
36
43
  mouseNoticeSeen: boolean;
44
+ /**
45
+ * Which revision of the mouse notice this user has already been shown
46
+ * (`0` = none).
47
+ *
48
+ * A VERSION BESIDE THE BOOLEAN, NOT A BUMPED SCHEMA, and this is the second
49
+ * time this file has had to say so. The audience for the CORRECTED text — "drag
50
+ * to select, releasing copies" — is exactly the set of users who already have
51
+ * `mouseNoticeSeen: true` on disk from the old "hold Shift" text, so a boolean
52
+ * can only say "shown", never "shown WHAT". And bumping `UI_STATE_SCHEMA`, the
53
+ * nearest thing to "bump the key", makes `readFromDisk` discard the WHOLE
54
+ * object: every user's `submitCount` resets and the composer hint un-fades for
55
+ * all of them, to re-show one notice.
56
+ *
57
+ * `vtInputNoticeVersion` below is the precedent, written about this exact
58
+ * situation one feature earlier.
59
+ */
60
+ mouseNoticeVersion: number;
37
61
  /**
38
62
  * Which revision of the "this Windows console cannot deliver Shift+Tab or the
39
63
  * wheel" notice this user has already been shown (`0` = none).
@@ -69,6 +93,9 @@ export declare function bumpSubmitCount(): number;
69
93
  export declare function getMouseNoticeSeen(): boolean;
70
94
  export declare function setMouseNoticeSeen(seen: boolean): void;
71
95
  /** `0` on a file written before the field existed — see the field's comment. */
96
+ export declare function getMouseNoticeVersion(): number;
97
+ export declare function setMouseNoticeVersion(version: number): void;
98
+ /** `0` on a file written before the field existed — see the field's comment. */
72
99
  export declare function getVtInputNoticeVersion(): number;
73
100
  export declare function setVtInputNoticeVersion(version: number): void;
74
101
  /**
@@ -35,6 +35,7 @@ const DEFAULT_UI_STATE = {
35
35
  schema: UI_STATE_SCHEMA,
36
36
  submitCount: 0,
37
37
  mouseNoticeSeen: false,
38
+ mouseNoticeVersion: 0,
38
39
  vtInputNoticeVersion: 0,
39
40
  };
40
41
  let memory = null;
@@ -66,6 +67,7 @@ function readFromDisk() {
66
67
  mouseNoticeSeen: typeof parsed.mouseNoticeSeen === 'boolean'
67
68
  ? parsed.mouseNoticeSeen
68
69
  : DEFAULT_UI_STATE.mouseNoticeSeen,
70
+ mouseNoticeVersion: coerceCount(parsed.mouseNoticeVersion, DEFAULT_UI_STATE.mouseNoticeVersion),
69
71
  vtInputNoticeVersion: coerceCount(parsed.vtInputNoticeVersion, DEFAULT_UI_STATE.vtInputNoticeVersion),
70
72
  };
71
73
  }
@@ -127,6 +129,13 @@ export function setMouseNoticeSeen(seen) {
127
129
  update({ mouseNoticeSeen: seen });
128
130
  }
129
131
  /** `0` on a file written before the field existed — see the field's comment. */
132
+ export function getMouseNoticeVersion() {
133
+ return loadUiState().mouseNoticeVersion;
134
+ }
135
+ export function setMouseNoticeVersion(version) {
136
+ update({ mouseNoticeVersion: version });
137
+ }
138
+ /** `0` on a file written before the field existed — see the field's comment. */
130
139
  export function getVtInputNoticeVersion() {
131
140
  return loadUiState().vtInputNoticeVersion;
132
141
  }
@@ -157,6 +157,55 @@ export interface ExecFastReviewEvent {
157
157
  model: string;
158
158
  text: string;
159
159
  }
160
+ /**
161
+ * Context compaction (context-auto-compaction §4.6).
162
+ *
163
+ * ONE ADDITIVE EVENT TYPE, AND `EXEC_SCHEMA_VERSION` DOES NOT MOVE — the rule
164
+ * this file's header states in the same words: adding a field or an event type
165
+ * never bumps it; renaming or removing one does. A consumer that ignores event
166
+ * types it does not know is unaffected.
167
+ *
168
+ * THE SUMMARY TEXT IS DELIBERATELY ABSENT (D-18). It can be tens of thousands of
169
+ * characters, no wrapper needs it, and the transcript card and the log both have
170
+ * it. What a wrapper does need is the SHAPE of what happened, which is here.
171
+ */
172
+ export interface ExecCompactionEvent {
173
+ type: 'compaction';
174
+ sessionId: string;
175
+ turn: number;
176
+ subtype: 'start' | 'end';
177
+ trigger: 'pressure' | 'overflow' | 'manual';
178
+ /** `end` only. */
179
+ applied?: boolean;
180
+ /**
181
+ * `relieved` means the RETAINED turns were clipped and nothing was dropped
182
+ * (context-auto-compaction-hardening §3.3 / W2).
183
+ *
184
+ * A NEW MEMBER OF AN EXISTING UNION, WHICH DOES NOT MOVE `EXEC_SCHEMA_VERSION`
185
+ * either - the rule this file's header states covers adding, not renaming or
186
+ * removing. A consumer switching on this field already has to handle a value it
187
+ * does not know, because `none` and `truncated` were both already possible.
188
+ */
189
+ mode?: 'summarized' | 'truncated' | 'relieved' | 'none';
190
+ reason?: string;
191
+ messagesBefore?: number;
192
+ messagesAfter?: number;
193
+ tokensBefore?: number;
194
+ tokensAfter?: number;
195
+ /**
196
+ * Present only when tail relief fired (§4.5).
197
+ *
198
+ * A BOUNDED, ANNOUNCED DATA LOSS INSIDE THE TURNS THE RUN CONTINUES FROM, and
199
+ * therefore something a wrapper is entitled to see. The clipped bodies
200
+ * themselves are not on the stream, for the same reason `summary` is not
201
+ * (D-18).
202
+ */
203
+ tailRelief?: {
204
+ messages: number;
205
+ charsRemoved: number;
206
+ };
207
+ durationMs?: number;
208
+ }
160
209
  export interface ExecRetryEvent {
161
210
  type: 'retry';
162
211
  sessionId: string;
@@ -202,7 +251,7 @@ export interface ExecResultEvent {
202
251
  todos: ExecTodoCounts | null;
203
252
  error: ExecErrorInfo | null;
204
253
  }
205
- export type ExecEvent = ExecSystemInitEvent | ExecUserEvent | ExecTextDeltaEvent | ExecThinkingEvent | ExecAssistantEvent | ExecToolCallEvent | ExecToolResultEvent | ExecTodoEvent | ExecTeamEvent | ExecFastReviewEvent | ExecRetryEvent | ExecErrorEvent | ExecResultEvent;
254
+ export type ExecEvent = ExecSystemInitEvent | ExecUserEvent | ExecTextDeltaEvent | ExecThinkingEvent | ExecAssistantEvent | ExecToolCallEvent | ExecToolResultEvent | ExecTodoEvent | ExecTeamEvent | ExecFastReviewEvent | ExecCompactionEvent | ExecRetryEvent | ExecErrorEvent | ExecResultEvent;
206
255
  /** Everything `system/init` needs that the runner cannot derive for itself. */
207
256
  export interface ExecInitParams {
208
257
  sessionId: string;
@@ -121,6 +121,48 @@ export class ExecRunner {
121
121
  });
122
122
  if (fast)
123
123
  this.unsubscribers.push(fast);
124
+ // Context compaction (context-auto-compaction §4.6). Usage is folded into the
125
+ // run's totals for the reason the team and fast branches above give: a
126
+ // summarization is real spend, and `result.usage` is the only cost readout a
127
+ // wrapper gets.
128
+ const compaction = controller.subscribeCompaction?.((event) => {
129
+ if (event.type === 'usage') {
130
+ this.usage.inputTokens += event.usage.inputTokens;
131
+ this.usage.outputTokens += event.usage.outputTokens;
132
+ return;
133
+ }
134
+ if (event.type === 'compaction_start') {
135
+ this.emit({
136
+ type: 'compaction',
137
+ sessionId: this.sessionId,
138
+ turn: this.turns,
139
+ subtype: 'start',
140
+ trigger: event.trigger,
141
+ });
142
+ return;
143
+ }
144
+ if (event.type !== 'compaction_end')
145
+ return;
146
+ const r = event.record;
147
+ this.emit({
148
+ type: 'compaction',
149
+ sessionId: this.sessionId,
150
+ turn: this.turns,
151
+ subtype: 'end',
152
+ trigger: r.trigger,
153
+ applied: r.applied,
154
+ mode: r.mode,
155
+ ...(r.reason !== undefined ? { reason: r.reason } : {}),
156
+ messagesBefore: r.messagesBefore,
157
+ messagesAfter: r.messagesAfter,
158
+ tokensBefore: r.tokensBefore,
159
+ tokensAfter: r.tokensAfter,
160
+ ...(r.tailRelief ? { tailRelief: r.tailRelief } : {}),
161
+ durationMs: r.durationMs,
162
+ });
163
+ });
164
+ if (compaction)
165
+ this.unsubscribers.push(compaction);
124
166
  this.installTerminator();
125
167
  }
126
168
  /**
@@ -15,8 +15,10 @@
15
15
  * module exists so `stdin-mouse-filter.ts` can remove every byte of every
16
16
  * report before Ink ever sees the chunk (invariant I-1).
17
17
  *
18
- * Non-wheel reports (press / release / drag) are recognized and DISCARDED
19
- * rather than passed through, for the same reason.
18
+ * Every mouse report is CONSUMED here whatever it turns out to be; what changed
19
+ * with tui-selection-and-scroll-follow is that press / drag / release are now
20
+ * EMITTED as well as consumed, so `SelectionController` can build a selection
21
+ * out of them (§4.4.2). They were previously recognized and thrown away.
20
22
  */
21
23
  export interface WheelEvent {
22
24
  readonly kind: 'wheel';
@@ -29,14 +31,33 @@ export interface WheelEvent {
29
31
  readonly alt: boolean;
30
32
  readonly ctrl: boolean;
31
33
  }
34
+ export interface ButtonEvent {
35
+ readonly kind: 'press' | 'drag' | 'release';
36
+ /** 0 = left, 1 = middle, 2 = right. Never 3 — see `decodeReport`. */
37
+ readonly button: 0 | 1 | 2;
38
+ /** 1-based terminal column, as reported. */
39
+ readonly x: number;
40
+ /** 1-based terminal row, as reported. */
41
+ readonly y: number;
42
+ readonly shift: boolean;
43
+ readonly alt: boolean;
44
+ readonly ctrl: boolean;
45
+ }
46
+ export type MouseEvent = WheelEvent | ButtonEvent;
32
47
  export interface MouseSplit {
33
- /** Wheel events, in arrival order. Non-wheel reports are dropped, not returned. */
34
- readonly events: WheelEvent[];
48
+ /** Mouse events, in arrival order. Unrecognized reports are dropped, not returned. */
49
+ readonly events: MouseEvent[];
35
50
  /** Everything that was not a mouse report — forward this to Ink verbatim. */
36
51
  readonly text: string;
37
52
  /** Trailing strict prefix of a possible report; feed it back in with the next chunk. */
38
53
  readonly pending: string;
39
54
  }
55
+ /**
56
+ * Which wire encoding a report arrived in. It is a PARAMETER rather than a
57
+ * remembered rule because the rule it expresses is invariant I-11 and the cost
58
+ * of forgetting it is a permanently frozen viewport (see `decodeReport`).
59
+ */
60
+ export type MouseEncoding = 'sgr' | 'x10';
40
61
  /**
41
62
  * Ceiling on the held-back tail. A malformed sequence that looks like a prefix
42
63
  * forever must never be able to wedge input: past this length the tail is
@@ -51,11 +72,16 @@ export declare const MAX_PENDING_MOUSE_CHARS = 32;
51
72
  */
52
73
  export declare function isMousePrefix(s: string): boolean;
53
74
  /**
54
- * Split a decoded stdin chunk into wheel events, passthrough text, and a
75
+ * Split a decoded stdin chunk into mouse events, passthrough text, and a
55
76
  * trailing prefix to carry into the next chunk.
56
77
  *
57
78
  * The caller is expected to feed `pending + nextChunk` back in; ConPTY does
58
79
  * split writes mid-sequence, and a report torn across two chunks would
59
80
  * otherwise leak its tail into the composer (R-6).
81
+ *
82
+ * ONE CHANNEL, NOT TWO (§4.4.2). Widening this list to carry buttons alongside
83
+ * wheel notches is what keeps ARRIVAL ORDER intact between them; a second
84
+ * channel would let a press overtake the notch that preceded it, and the
85
+ * selection would then be anchored against rows that had already scrolled.
60
86
  */
61
87
  export declare function splitMouseEvents(buffer: string): MouseSplit;
@@ -15,8 +15,10 @@
15
15
  * module exists so `stdin-mouse-filter.ts` can remove every byte of every
16
16
  * report before Ink ever sees the chunk (invariant I-1).
17
17
  *
18
- * Non-wheel reports (press / release / drag) are recognized and DISCARDED
19
- * rather than passed through, for the same reason.
18
+ * Every mouse report is CONSUMED here whatever it turns out to be; what changed
19
+ * with tui-selection-and-scroll-follow is that press / drag / release are now
20
+ * EMITTED as well as consumed, so `SelectionController` can build a selection
21
+ * out of them (§4.4.2). They were previously recognized and thrown away.
20
22
  */
21
23
  /**
22
24
  * Ceiling on the held-back tail. A malformed sequence that looks like a prefix
@@ -38,34 +40,80 @@ const SGR_PREFIX = /^\x1b\[<[\d;]*$/;
38
40
  export function isMousePrefix(s) {
39
41
  return /^\x1B(\[(<[\d;]*|M[\s\S]{0,2})?)?$/.test(s);
40
42
  }
43
+ function modifiers(b) {
44
+ return { shift: (b & 4) !== 0, alt: (b & 8) !== 0, ctrl: (b & 16) !== 0 };
45
+ }
41
46
  /**
42
- * Decode an SGR button byte into a wheel event, or `null` when the report is
43
- * not a wheel notch we act on.
47
+ * Decode one report, or `null` when it is a report we consume without acting on.
44
48
  *
45
49
  * THE ORDER OF THESE TESTS IS THE SPECIFICATION, not a presentation choice.
46
50
  * Button 66 (wheel-left) has `b & 1 === 0` and would decode as `'up'`, so the
47
- * horizontal test has to precede the direction decode.
51
+ * horizontal test has to precede the direction decode; and `b & 32` (motion) has
52
+ * to precede the `final` test, because a drag is reported with `M` exactly as a
53
+ * press is.
54
+ *
55
+ * ═══════════════════════════════════════════════════════════════════════════
56
+ * I-11 — BUTTON EVENTS COME FROM THE SGR PATH ONLY. X10 stays wheel-only, which
57
+ * is exactly the contract it has today.
58
+ *
59
+ * `matchX10` reconstructs a legacy report and hard-codes `final = 'M'`, because
60
+ * X10 HAS NO SEPARATE RELEASE FORM AT ALL — it encodes a release as button 3 of
61
+ * a press. Fed through the table below, an X10 release would become a `press`
62
+ * that is never followed by a `release`, whose `button` is 3 while the type says
63
+ * `0 | 1 | 2`, and whose consequence is a `hold` that is set and never cleared:
64
+ * content anchoring never lets go, the resume timer's third condition never
65
+ * passes, and the transcript is frozen for the rest of the session with nothing
66
+ * raised anywhere.
67
+ *
68
+ * This costs nothing real (D-16): we never REQUEST X10 — `?1006h` is over a
69
+ * decade old and every terminal that implements `?1000` implements it — and a
70
+ * terminal that ignored the SGR request also has no usable drag coordinates past
71
+ * column 223 (R-2). X10 exists in this parser only so a stray report cannot
72
+ * reach the user's draft.
73
+ * ═══════════════════════════════════════════════════════════════════════════
48
74
  */
49
- function decodeWheel(b, x, y, final) {
50
- // 1. Not a wheel report at all (press / release / drag) — consume, emit nothing.
51
- if ((b & 64) === 0)
52
- return null;
53
- // 2. Horizontal wheel (66 = left, 67 = right). Consumed and dropped (non-goal).
54
- if ((b & 3) >= 2)
55
- return null;
56
- // 3. Wheel buttons are press-only; a terminal that ever sent a release for one
57
- // must not produce a second step for the same physical notch.
58
- if (final !== 'M')
75
+ function decodeReport(b, x, y, final, encoding) {
76
+ // 1. Wheel. Buttons 64-67 in both encodings.
77
+ if ((b & 64) !== 0) {
78
+ // Horizontal wheel (66 = left, 67 = right). Consumed and dropped (non-goal).
79
+ if ((b & 3) >= 2)
80
+ return null;
81
+ // Wheel buttons are press-only; a terminal that ever sent a release for one
82
+ // must not produce a second step for the same physical notch.
83
+ if (final !== 'M')
84
+ return null;
85
+ return { kind: 'wheel', dir: (b & 1) === 1 ? 'down' : 'up', x, y, ...modifiers(b) };
86
+ }
87
+ if (encoding !== 'sgr')
88
+ return null; // I-11
89
+ // 2. Motion while a button is held (`?1002h`). `3` is "no button", which
90
+ // `?1002` should never send and `?1003` sends constantly — dropping it is
91
+ // what keeps an any-event terminal from painting a selection with no button
92
+ // down at all.
93
+ if ((b & 32) !== 0) {
94
+ const button = b & 3;
95
+ if (button === 3)
96
+ return null;
97
+ return { kind: 'drag', button: button, x, y, ...modifiers(b) };
98
+ }
99
+ // 3. Release. In SGR the final byte carries the transition, so this is
100
+ // unambiguous — unlike X10, which is why step 1's guard exists.
101
+ //
102
+ // A release whose button field reads `3` is a terminal saying "some button
103
+ // came up" without naming it. It is mapped to button 0 rather than dropped:
104
+ // the ONE thing a release must always do is let go of `hold`, and dropping
105
+ // it would leave the viewport frozen — the exact failure I-11 is about. A
106
+ // spurious button-0 release is harmless, because a selection can only exist
107
+ // if button 0 was pressed in the first place.
108
+ if (final === 'm') {
109
+ const button = b & 3;
110
+ return { kind: 'release', button: (button === 3 ? 0 : button), x, y, ...modifiers(b) };
111
+ }
112
+ // 4. Press.
113
+ const button = b & 3;
114
+ if (button === 3)
59
115
  return null;
60
- return {
61
- kind: 'wheel',
62
- dir: (b & 1) === 1 ? 'down' : 'up',
63
- x,
64
- y,
65
- shift: (b & 4) !== 0,
66
- alt: (b & 8) !== 0,
67
- ctrl: (b & 16) !== 0,
68
- };
116
+ return { kind: 'press', button: button, x, y, ...modifiers(b) };
69
117
  }
70
118
  /**
71
119
  * X10 legacy reports (`\x1b[M` + three chars, each `code - 32`).
@@ -84,7 +132,13 @@ function matchX10(s) {
84
132
  if (codes.some((c) => c < 32 || c > 255))
85
133
  return { kind: 'complete', length: 6, event: null };
86
134
  const [b, x, y] = codes;
87
- return { kind: 'complete', length: 6, event: decodeWheel(b - 32, x - 32, y - 32, 'M') };
135
+ return {
136
+ kind: 'complete',
137
+ length: 6,
138
+ // `'x10'` is what makes I-11 a property of the code rather than of somebody's
139
+ // memory: this encoding yields wheel events or nothing, ever.
140
+ event: decodeReport(b - 32, x - 32, y - 32, 'M', 'x10'),
141
+ };
88
142
  }
89
143
  /** Classify the escape sequence starting at `s[0] === ESC`. */
90
144
  function matchReport(s) {
@@ -100,7 +154,7 @@ function matchReport(s) {
100
154
  const b = Number(m[1]);
101
155
  const x = Number(m[2]);
102
156
  const y = Number(m[3]);
103
- return { kind: 'complete', length: m[0].length, event: decodeWheel(b, x, y, m[4]) };
157
+ return { kind: 'complete', length: m[0].length, event: decodeReport(b, x, y, m[4], 'sgr') };
104
158
  }
105
159
  return SGR_PREFIX.test(s) ? { kind: 'partial' } : { kind: 'none' };
106
160
  }
@@ -109,12 +163,17 @@ function matchReport(s) {
109
163
  return { kind: 'none' };
110
164
  }
111
165
  /**
112
- * Split a decoded stdin chunk into wheel events, passthrough text, and a
166
+ * Split a decoded stdin chunk into mouse events, passthrough text, and a
113
167
  * trailing prefix to carry into the next chunk.
114
168
  *
115
169
  * The caller is expected to feed `pending + nextChunk` back in; ConPTY does
116
170
  * split writes mid-sequence, and a report torn across two chunks would
117
171
  * otherwise leak its tail into the composer (R-6).
172
+ *
173
+ * ONE CHANNEL, NOT TWO (§4.4.2). Widening this list to carry buttons alongside
174
+ * wheel notches is what keeps ARRIVAL ORDER intact between them; a second
175
+ * channel would let a press overtake the notch that preceded it, and the
176
+ * selection would then be anchored against rows that had already scrolled.
118
177
  */
119
178
  export function splitMouseEvents(buffer) {
120
179
  const events = [];
@@ -18,10 +18,15 @@
18
18
  * than the bug this feature fixes. `cli.tsx::runInteractive` is the only caller
19
19
  * and passes `filter !== null` to `enterAltScreen`.
20
20
  */
21
- import { type WheelEvent } from './mouse-events.js';
21
+ import { type MouseEvent } from './mouse-events.js';
22
22
  export interface MouseSource {
23
- /** Returns an unsubscribe function. Multiple subscribers are allowed. */
24
- subscribe(listener: (event: WheelEvent) => void): () => void;
23
+ /**
24
+ * Returns an unsubscribe function. Multiple subscribers are allowed — the
25
+ * wheel router and (since tui-selection-and-scroll-follow) the selection
26
+ * controller both take this one channel, and each ignores the kinds it does
27
+ * not own.
28
+ */
29
+ subscribe(listener: (event: MouseEvent) => void): () => void;
25
30
  }
26
31
  export interface MouseFilter {
27
32
  /** Hand this to Ink: `render(<App/>, { stdin: filter.stdin })`. */