@aragon-agent/cli 0.5.6 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/README.md +1429 -1105
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/entry-limits.d.ts +65 -0
  8. package/dist/agent/entry-limits.js +133 -0
  9. package/dist/agent/headless.d.ts +8 -0
  10. package/dist/agent/headless.js +40 -0
  11. package/dist/agent/reducer.d.ts +253 -3
  12. package/dist/agent/reducer.js +553 -28
  13. package/dist/agent/retry-view.d.ts +68 -0
  14. package/dist/agent/retry-view.js +95 -0
  15. package/dist/agent/system-prompt.d.ts +11 -0
  16. package/dist/agent/system-prompt.js +2 -0
  17. package/dist/cli.js +122 -4
  18. package/dist/commands/builtins.d.ts +1 -1
  19. package/dist/commands/builtins.js +313 -2
  20. package/dist/commands/perf.d.ts +45 -0
  21. package/dist/commands/perf.js +78 -0
  22. package/dist/config/cli-commands.d.ts +53 -2
  23. package/dist/config/cli-commands.js +136 -3
  24. package/dist/config/env.d.ts +6 -1
  25. package/dist/config/env.js +131 -1
  26. package/dist/config/load.d.ts +63 -0
  27. package/dist/config/load.js +125 -2
  28. package/dist/config/schema.d.ts +273 -10
  29. package/dist/config/schema.js +250 -7
  30. package/dist/config/store.js +20 -1
  31. package/dist/fast/critique.d.ts +43 -0
  32. package/dist/fast/critique.js +62 -0
  33. package/dist/fast/digest.d.ts +42 -0
  34. package/dist/fast/digest.js +85 -0
  35. package/dist/fast/frames.d.ts +99 -0
  36. package/dist/fast/frames.js +180 -0
  37. package/dist/fast/limits.d.ts +98 -0
  38. package/dist/fast/limits.js +98 -0
  39. package/dist/fast/prompt.d.ts +72 -0
  40. package/dist/fast/prompt.js +94 -0
  41. package/dist/fast/resolve.d.ts +43 -0
  42. package/dist/fast/resolve.js +105 -0
  43. package/dist/fast/review-call.d.ts +99 -0
  44. package/dist/fast/review-call.js +130 -0
  45. package/dist/fast/reviewer.d.ts +248 -0
  46. package/dist/fast/reviewer.js +664 -0
  47. package/dist/fast/types.d.ts +128 -0
  48. package/dist/fast/types.js +14 -0
  49. package/dist/fast/wiring.d.ts +170 -0
  50. package/dist/fast/wiring.js +316 -0
  51. package/dist/logging/install.d.ts +15 -1
  52. package/dist/logging/install.js +37 -4
  53. package/dist/logging/logger.d.ts +1 -1
  54. package/dist/session/persist.js +57 -1
  55. package/dist/team/normalize.d.ts +26 -1
  56. package/dist/team/normalize.js +23 -3
  57. package/dist/team/prompt.d.ts +11 -0
  58. package/dist/team/prompt.js +10 -0
  59. package/dist/team/report.d.ts +18 -0
  60. package/dist/team/report.js +27 -1
  61. package/dist/team/runtime.d.ts +13 -2
  62. package/dist/team/runtime.js +33 -6
  63. package/dist/team/subagent.d.ts +19 -1
  64. package/dist/team/subagent.js +72 -10
  65. package/dist/team/task-tool.d.ts +26 -0
  66. package/dist/team/task-tool.js +48 -4
  67. package/dist/team/types.d.ts +80 -1
  68. package/dist/tools/bash-tool.js +19 -3
  69. package/dist/tools/diff.d.ts +25 -5
  70. package/dist/tools/diff.js +72 -41
  71. package/dist/tools/file-change-store.d.ts +34 -0
  72. package/dist/tools/file-change-store.js +57 -0
  73. package/dist/tools/fs-tools.d.ts +26 -0
  74. package/dist/tools/fs-tools.js +62 -5
  75. package/dist/tools/index.d.ts +31 -0
  76. package/dist/tools/index.js +7 -1
  77. package/dist/tools/patch.d.ts +115 -0
  78. package/dist/tools/patch.js +453 -0
  79. package/dist/tools/terminal-output.d.ts +107 -0
  80. package/dist/tools/terminal-output.js +198 -0
  81. package/dist/tools/tool-output-store.d.ts +68 -0
  82. package/dist/tools/tool-output-store.js +102 -0
  83. package/dist/ui/ActivityLine.d.ts +48 -0
  84. package/dist/ui/ActivityLine.js +22 -0
  85. package/dist/ui/App.d.ts +1 -4
  86. package/dist/ui/App.js +357 -44
  87. package/dist/ui/BottomStatusRow.d.ts +43 -0
  88. package/dist/ui/BottomStatusRow.js +14 -0
  89. package/dist/ui/Composer.d.ts +1 -6
  90. package/dist/ui/Composer.js +2 -2
  91. package/dist/ui/Markdown.d.ts +7 -1
  92. package/dist/ui/Markdown.js +15 -48
  93. package/dist/ui/PromptInput.d.ts +1 -15
  94. package/dist/ui/PromptInput.js +1 -38
  95. package/dist/ui/StatusBar.d.ts +50 -0
  96. package/dist/ui/StatusBar.js +7 -3
  97. package/dist/ui/TeamPanel.d.ts +5 -4
  98. package/dist/ui/TeamPanel.js +1 -1
  99. package/dist/ui/TodoPanel.d.ts +5 -4
  100. package/dist/ui/Transcript.d.ts +80 -5
  101. package/dist/ui/Transcript.js +179 -11
  102. package/dist/ui/activity-phrases.d.ts +33 -0
  103. package/dist/ui/activity-phrases.js +50 -0
  104. package/dist/ui/entries/AssistantEntry.d.ts +38 -1
  105. package/dist/ui/entries/AssistantEntry.js +34 -3
  106. package/dist/ui/entries/DiffView.d.ts +67 -0
  107. package/dist/ui/entries/DiffView.js +103 -0
  108. package/dist/ui/entries/FastCard.d.ts +53 -0
  109. package/dist/ui/entries/FastCard.js +87 -0
  110. package/dist/ui/entries/RetryCard.d.ts +56 -0
  111. package/dist/ui/entries/RetryCard.js +60 -0
  112. package/dist/ui/entries/TeamCard.d.ts +9 -1
  113. package/dist/ui/entries/TeamCard.js +28 -2
  114. package/dist/ui/entries/TodoCard.d.ts +9 -1
  115. package/dist/ui/entries/TodoCard.js +23 -1
  116. package/dist/ui/entries/ToolCard.d.ts +50 -1
  117. package/dist/ui/entries/ToolCard.js +104 -7
  118. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  119. package/dist/ui/entries/UserEntry.d.ts +9 -1
  120. package/dist/ui/entries/UserEntry.js +17 -1
  121. package/dist/ui/exit-snapshot.d.ts +7 -0
  122. package/dist/ui/glyphs.d.ts +9 -0
  123. package/dist/ui/glyphs.js +2 -0
  124. package/dist/ui/layout/AppShell.d.ts +13 -22
  125. package/dist/ui/layout/AppShell.js +3 -38
  126. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  127. package/dist/ui/layout/MeasuredEntry.js +30 -0
  128. package/dist/ui/layout/ScrollViewport.js +8 -2
  129. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  130. package/dist/ui/layout/viewport-geometry.js +27 -0
  131. package/dist/ui/layout/virtual-window.d.ts +100 -0
  132. package/dist/ui/layout/virtual-window.js +308 -0
  133. package/dist/ui/live-clamp.d.ts +42 -0
  134. package/dist/ui/live-clamp.js +74 -0
  135. package/dist/ui/markdown-blocks.d.ts +25 -1
  136. package/dist/ui/markdown-blocks.js +43 -0
  137. package/dist/ui/overlays/HelpOverlay.js +16 -2
  138. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  139. package/dist/ui/overlays/SettingsScreen.js +147 -1
  140. package/dist/ui/render-cache.d.ts +81 -0
  141. package/dist/ui/render-cache.js +195 -0
  142. package/dist/ui/render-governor.d.ts +54 -0
  143. package/dist/ui/render-governor.js +78 -0
  144. package/dist/ui/transcript-text.d.ts +11 -0
  145. package/dist/ui/transcript-text.js +32 -0
  146. package/dist/ui/use-height-store.d.ts +31 -0
  147. package/dist/ui/use-height-store.js +76 -0
  148. package/dist/ui/use-render-governor.d.ts +39 -0
  149. package/dist/ui/use-render-governor.js +57 -0
  150. package/dist/ui/use-startup-notices.d.ts +9 -1
  151. package/dist/ui/use-startup-notices.js +35 -1
  152. package/dist/ui/use-wheel-routing.d.ts +16 -32
  153. package/dist/ui/use-wheel-routing.js +21 -38
  154. package/package.json +2 -2
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import React from 'react';
11
11
  import { type ThinkingLevel } from '@aragon-agent/core';
12
+ import { type FastConfig } from '../../config/schema.js';
12
13
  import { type LogLevelName } from '../../logging/levels.js';
13
14
  import type { Theme } from '../theme.js';
14
15
  import type { TermCapabilities } from '../capabilities.js';
@@ -17,6 +18,22 @@ export interface SettingsValues {
17
18
  model: string;
18
19
  baseUrl: string;
19
20
  thinkingLevel: ThinkingLevel;
21
+ /**
22
+ * Whether the transcript draws the reasoning (agent-activity-presentation
23
+ * §4.3). `'on' | 'off'` rather than a boolean because this screen edits an
24
+ * enum row and reads it back as one; `App` converts at both ends.
25
+ */
26
+ showThinking: 'on' | 'off';
27
+ /**
28
+ * Whether a running tool card draws a live output tail
29
+ * (agent-activity-presentation-live §4.1). `'on' | 'off'` for the reason its
30
+ * neighbour records: this screen edits an enum row and reads it back as one.
31
+ *
32
+ * PERSIST-ONLY, unlike `showThinking`. The store is allocated in
33
+ * `AgentController`'s constructor and there is no setter, so this row takes
34
+ * effect on the next launch -- which is also what manual row 8 asks for.
35
+ */
36
+ liveToolOutput: 'on' | 'off';
20
37
  maxTokens: string;
21
38
  apiKey: string;
22
39
  /**
@@ -25,6 +42,21 @@ export interface SettingsValues {
25
42
  * credentials on disk cannot be flipped by a stray arrow key.
26
43
  */
27
44
  logLevel: LogLevelName;
45
+ /**
46
+ * The fast tier (fast-model-tier §4.5, extended by the hardening round §4.4).
47
+ *
48
+ * FIVE ROWS, and only the first is always meaningful — the other four
49
+ * describe a tier that may not be on. They are `string` rather than their
50
+ * config types because this screen edits TEXT: `fastReview` accepts a number
51
+ * or `off`, and a typo must change nothing rather than resolve to a default
52
+ * (the discipline `maxTokens` already follows here).
53
+ */
54
+ fastEnabled: 'on' | 'off';
55
+ fastModel: string;
56
+ fastProvider: string;
57
+ fastReview: string;
58
+ /** The session review budget. `fastReview` is the PACE, this is the TOTAL. */
59
+ fastReviewBudget: string;
28
60
  }
29
61
  interface SettingsScreenProps {
30
62
  initial: SettingsValues;
@@ -39,7 +71,56 @@ interface SettingsScreenProps {
39
71
  theme: Theme;
40
72
  caps: TermCapabilities;
41
73
  onSave: (values: SettingsValues) => void;
74
+ /**
75
+ * `API retries` READ-ONLY row (llm-api-retry-backoff §6.9), e.g. `10` or `off`.
76
+ *
77
+ * A STRING FROM `App`, NOT A `SettingsValues` FIELD, and that distinction is the
78
+ * whole design: `SettingsValues` is what Enter WRITES, and routing retry through
79
+ * it would mean nine more editable rows (or one row whose Enter silently
80
+ * rewrote a section it does not own). `/retry` is the discoverable control;
81
+ * this row exists so "is it on, and how many" is answerable without leaving the
82
+ * screen. Absent = omit the row entirely.
83
+ */
84
+ retrySummary?: string;
42
85
  }
86
+ /**
87
+ * The derived, non-editable line under the five fast rows — the sibling of
88
+ * `effectiveCapLine`, and it exists for the same reason (§4.5).
89
+ *
90
+ * A SETTINGS SCREEN THAT LETS A USER SWITCH A TIER ON AND GIVES NO SIGNAL THAT
91
+ * IT CANNOT RESOLVE is the failure `effectiveCapLine` was added to fix for
92
+ * `maxTokens`, one screen later. `keyFor` is the same resolver the controller
93
+ * uses, so this line and the runtime cannot disagree.
94
+ *
95
+ * IT DOES NOT TAKE `fastReviewBudget` AND MUST NOT (§4.4 part 5): this line
96
+ * describes what the tier IS, not what it is allowed to spend.
97
+ */
98
+ export declare function fastTierLine(values: Pick<SettingsValues, 'provider' | 'fastEnabled' | 'fastModel' | 'fastProvider' | 'fastReview'>, hasKey: (providerId: string) => boolean): string;
99
+ /**
100
+ * The five editable fast rows, as a config patch (§4.5 / hardening §4.4).
101
+ *
102
+ * PURE AND EXPORTED so `App`'s save handler holds no parsing of its own, and so
103
+ * the round trip `fastSettingsFrom(config)` -> edit -> `readFastSettings` can be
104
+ * tested without a terminal.
105
+ *
106
+ * A TYPO IN `fastReview` CHANGES NOTHING — the `review` / `reviewEveryTurns`
107
+ * keys are simply omitted from the patch, so `clampFastConfig`'s merge keeps
108
+ * whatever was there. That is the `Max tokens` discipline this screen already
109
+ * follows, and the alternative (resolving a typo to the default) silently
110
+ * discards the cadence the user actually set. `fastReviewBudget` follows the
111
+ * same rule; an out-of-range NUMBER is passed through to `clampFastConfig`'s
112
+ * `REVIEW_SESSION_RANGE` rather than being validated a second time here, so a
113
+ * row edit and a hand-edited `config.json` land on the same value.
114
+ */
115
+ export declare function readFastSettings(values: Pick<SettingsValues, 'fastEnabled' | 'fastModel' | 'fastProvider' | 'fastReview' | 'fastReviewBudget'>): Partial<FastConfig>;
116
+ /**
117
+ * The inverse: seed the five rows from a resolved config.
118
+ *
119
+ * OMITTING A KEY HERE IS THE SILENT HALF OF ADDING A ROW (§4.4 part 4): the row
120
+ * renders empty, `readFastSettings` sees an unparseable value, and an untouched
121
+ * save writes the clamp's default over whatever the user had.
122
+ */
123
+ export declare function fastSettingsFrom(fast: FastConfig): Pick<SettingsValues, 'fastEnabled' | 'fastModel' | 'fastProvider' | 'fastReview' | 'fastReviewBudget'>;
43
124
  /**
44
125
  * The cap this setting will ACTUALLY produce against the selected model.
45
126
  *
@@ -51,5 +132,5 @@ interface SettingsScreenProps {
51
132
  * "I set 64000 but answers still stop early" has no answer anywhere in the UI.
52
133
  */
53
134
  export declare function effectiveCapLine(values: Pick<SettingsValues, 'provider' | 'model' | 'maxTokens'>): string;
54
- export declare function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onSave, }: SettingsScreenProps): React.ReactElement;
135
+ export declare function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onSave, retrySummary, }: SettingsScreenProps): React.ReactElement;
55
136
  export {};
@@ -15,12 +15,137 @@ import { ADAPTER_PROVIDERS, THINKING_LEVELS, maskSecret, parseMaxTokensInput, }
15
15
  import { LOG_LEVEL_NAMES } from '../../logging/levels.js';
16
16
  import { pickGlyphs } from '../glyphs.js';
17
17
  import { OverlayFrame } from '../layout/OverlayFrame.js';
18
+ const SHOW_THINKING_OPTIONS = ['off', 'on'];
19
+ const LIVE_OUTPUT_OPTIONS = ['off', 'on'];
20
+ const FAST_ENABLED_OPTIONS = ['off', 'on'];
21
+ /**
22
+ * `(inherit)` IS THE EMPTY STRING, rendered as a word.
23
+ *
24
+ * An empty enum cell would read as "unset and therefore broken"; the truth is
25
+ * that an empty `fast.provider` INHERITS the main provider, which is the setting
26
+ * most users want and the one they should not have to type (§3.2 rule 2).
27
+ */
28
+ const FAST_INHERIT_PROVIDER = '(inherit)';
29
+ const FAST_PROVIDER_OPTIONS = [FAST_INHERIT_PROVIDER, ...ADAPTER_PROVIDERS];
30
+ /**
31
+ * The derived, non-editable line under the five fast rows — the sibling of
32
+ * `effectiveCapLine`, and it exists for the same reason (§4.5).
33
+ *
34
+ * A SETTINGS SCREEN THAT LETS A USER SWITCH A TIER ON AND GIVES NO SIGNAL THAT
35
+ * IT CANNOT RESOLVE is the failure `effectiveCapLine` was added to fix for
36
+ * `maxTokens`, one screen later. `keyFor` is the same resolver the controller
37
+ * uses, so this line and the runtime cannot disagree.
38
+ *
39
+ * IT DOES NOT TAKE `fastReviewBudget` AND MUST NOT (§4.4 part 5): this line
40
+ * describes what the tier IS, not what it is allowed to spend.
41
+ */
42
+ export function fastTierLine(values, hasKey) {
43
+ if (values.fastEnabled !== 'on')
44
+ return 'Fast tier: off';
45
+ const model = values.fastModel.trim();
46
+ if (model.length === 0)
47
+ return 'Fast tier: off (fast.model is not set)';
48
+ const provider = values.fastProvider === FAST_INHERIT_PROVIDER || values.fastProvider.trim().length === 0
49
+ ? values.provider
50
+ : values.fastProvider;
51
+ if (!hasKey(provider)) {
52
+ return `Fast tier: ${provider}:${model} (no API key for ${provider} - the tier will stay off)`;
53
+ }
54
+ const review = values.fastReview.trim().toLowerCase();
55
+ const cadence = review === 'off'
56
+ ? 'review off'
57
+ : /^\d+$/.test(review)
58
+ ? `review every ${review} turns`
59
+ : 'review every 5 turns';
60
+ return `Fast tier: ${provider}:${model} (key set) ${cadence}`;
61
+ }
62
+ /**
63
+ * The five editable fast rows, as a config patch (§4.5 / hardening §4.4).
64
+ *
65
+ * PURE AND EXPORTED so `App`'s save handler holds no parsing of its own, and so
66
+ * the round trip `fastSettingsFrom(config)` -> edit -> `readFastSettings` can be
67
+ * tested without a terminal.
68
+ *
69
+ * A TYPO IN `fastReview` CHANGES NOTHING — the `review` / `reviewEveryTurns`
70
+ * keys are simply omitted from the patch, so `clampFastConfig`'s merge keeps
71
+ * whatever was there. That is the `Max tokens` discipline this screen already
72
+ * follows, and the alternative (resolving a typo to the default) silently
73
+ * discards the cadence the user actually set. `fastReviewBudget` follows the
74
+ * same rule; an out-of-range NUMBER is passed through to `clampFastConfig`'s
75
+ * `REVIEW_SESSION_RANGE` rather than being validated a second time here, so a
76
+ * row edit and a hand-edited `config.json` land on the same value.
77
+ */
78
+ export function readFastSettings(values) {
79
+ const review = values.fastReview.trim().toLowerCase();
80
+ const parsed = /^\d+$/.test(review) ? Number.parseInt(review, 10) : Number.NaN;
81
+ const budget = values.fastReviewBudget.trim();
82
+ const parsedBudget = /^\d+$/.test(budget) ? Number.parseInt(budget, 10) : Number.NaN;
83
+ return {
84
+ enabled: values.fastEnabled === 'on',
85
+ model: values.fastModel.trim(),
86
+ provider: values.fastProvider === FAST_INHERIT_PROVIDER ? '' : values.fastProvider.trim(),
87
+ ...(review === 'off' ? { review: false } : {}),
88
+ ...(Number.isFinite(parsed) && parsed > 0
89
+ ? { review: true, reviewEveryTurns: parsed }
90
+ : {}),
91
+ ...(Number.isFinite(parsedBudget) ? { reviewMaxPerSession: parsedBudget } : {}),
92
+ };
93
+ }
94
+ /**
95
+ * The inverse: seed the five rows from a resolved config.
96
+ *
97
+ * OMITTING A KEY HERE IS THE SILENT HALF OF ADDING A ROW (§4.4 part 4): the row
98
+ * renders empty, `readFastSettings` sees an unparseable value, and an untouched
99
+ * save writes the clamp's default over whatever the user had.
100
+ */
101
+ export function fastSettingsFrom(fast) {
102
+ return {
103
+ fastEnabled: fast.enabled ? 'on' : 'off',
104
+ fastModel: fast.model,
105
+ fastProvider: fast.provider.trim().length > 0 ? fast.provider : FAST_INHERIT_PROVIDER,
106
+ fastReview: fast.review ? String(fast.reviewEveryTurns) : 'off',
107
+ fastReviewBudget: String(fast.reviewMaxPerSession),
108
+ };
109
+ }
18
110
  const FIELDS = [
19
111
  { key: 'provider', label: 'Provider', kind: 'enum', options: ADAPTER_PROVIDERS },
20
112
  { key: 'model', label: 'Model', kind: 'text' },
21
113
  { key: 'baseUrl', label: 'Base URL', kind: 'text' },
22
114
  { key: 'thinkingLevel', label: 'Thinking', kind: 'enum', options: THINKING_LEVELS },
115
+ // Directly under `Thinking`, so the effort setting and the display setting are
116
+ // read together. `Show thinking` is 13 characters, inside the 18-column label
117
+ // budget `.padEnd(18)` enforces below and that IF-H7 records as silently
118
+ // overflowing.
119
+ { key: 'showThinking', label: 'Show thinking', kind: 'enum', options: SHOW_THINKING_OPTIONS },
120
+ // Beside `Show thinking`, because the two are the transcript's only display
121
+ // switches. `Live output` is 11 characters, inside the 18-column label budget
122
+ // `.padEnd(18)` enforces below and that IF-H7 records as silently overflowing.
123
+ { key: 'liveToolOutput', label: 'Live output', kind: 'enum', options: LIVE_OUTPUT_OPTIONS },
23
124
  { key: 'maxTokens', label: 'Max tokens', kind: 'text' },
125
+ // The fast tier sits AFTER `Max tokens` and BEFORE `API key`, because the key
126
+ // row is the one a user scrolls to deliberately and putting five rows past it
127
+ // would bury it (fast-model-tier §4.5). The rationale survives a fifth row;
128
+ // the count in it did not.
129
+ //
130
+ // `Fast review budget` is a `'text'` row, NOT a new numeric kind (D-H12):
131
+ // `SettingsRow.kind` is `'enum' | 'text' | 'secret'`, and `Max tokens` and
132
+ // `Fast review` are both bounded numbers already living as text plus an
133
+ // adapter parse. A fourth kind for one row would be a new interaction pattern
134
+ // in a screen whose whole design is that it edits text.
135
+ //
136
+ // ITS LABEL IS SHORT BECAUSE THE LABEL COLUMN IS A BUDGET, NOT A HINT SLOT.
137
+ // Labels render `.padEnd(18)` below, so one that reaches 18 does not truncate
138
+ // and does not wrap - it pushes ITS OWN value right (or butts against it at
139
+ // exactly 18) while every other row's value stays put, and the column stops
140
+ // being a column. So no `(1-500)` here: no other bounded row carries its range
141
+ // either (`Max tokens`, `Fast review`), `clampFastConfig` is the enforcement,
142
+ // and `/fast budget` states the range in the one place a user meets it - when
143
+ // it refuses a bad one. The name matches that command for the same reason.
144
+ { key: 'fastEnabled', label: 'Fast tier', kind: 'enum', options: FAST_ENABLED_OPTIONS },
145
+ { key: 'fastModel', label: 'Fast model', kind: 'text' },
146
+ { key: 'fastProvider', label: 'Fast provider', kind: 'enum', options: FAST_PROVIDER_OPTIONS },
147
+ { key: 'fastReview', label: 'Fast review', kind: 'text' },
148
+ { key: 'fastReviewBudget', label: 'Fast budget', kind: 'text' },
24
149
  { key: 'apiKey', label: 'API key', kind: 'secret' },
25
150
  { key: 'logLevel', label: 'Log level', kind: 'enum', options: LOG_LEVEL_NAMES },
26
151
  ];
@@ -52,7 +177,7 @@ export function effectiveCapLine(values) {
52
177
  return `Effective: ${resolution.value} (auto)`;
53
178
  return `Effective: ${resolution.value}`;
54
179
  }
55
- export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onSave, }) {
180
+ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset, onScrollClamp, theme, caps, onSave, retrySummary, }) {
56
181
  const [values, setValues] = useState(initial);
57
182
  const [index, setIndex] = useState(0);
58
183
  const glyphs = pickGlyphs(caps);
@@ -132,6 +257,15 @@ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset,
132
257
  // setting, which is the one thing this field can never be.
133
258
  if (f.key === 'maxTokens' && raw.length === 0)
134
259
  return 'auto';
260
+ // `(not set)` and NOT `(same as main)`, which is the honest reading of §3.2
261
+ // rule 3: an empty `fast.model` does not inherit, it means the tier is
262
+ // unconfigured. `(same as main)` is a fact the derived line below states
263
+ // when the value literally equals the main model — never a placeholder.
264
+ if (f.key === 'fastModel' && raw.length === 0) {
265
+ return selected ? '(type a model id)' : '(not set)';
266
+ }
267
+ if (f.key === 'fastReview' && raw.length === 0)
268
+ return 'every 5 turns';
135
269
  return raw.length > 0 ? raw : selected ? '(type to set)' : '(none)';
136
270
  };
137
271
  // One element per row, each `wrap="truncate"` — the contract for OverlayFrame's
@@ -145,6 +279,18 @@ export function SettingsScreen({ initial, apiKeys, maxRows, cols, scrollOffset,
145
279
  // controlled mode slices by ELEMENT, so a row that wraps desynchronises the
146
280
  // position indicator.
147
281
  rows.push(_jsx(Text, { wrap: "truncate", color: theme.muted, children: effectiveCapLine(values) }, "__effective"));
282
+ rows.push(_jsx(Text, { wrap: "truncate", color: theme.muted, children: fastTierLine(values, (id) => (apiKeys[id] ?? '').length > 0) }, "__fast"));
283
+ /**
284
+ * ONE ROW, NOT NINE (llm-api-retry-backoff §6.9).
285
+ *
286
+ * READ-ONLY, and deliberately so: the nine `retry.*` keys belong to `config set`
287
+ * and `config.json`, and a settings screen that exposed a jitter checkbox is a
288
+ * settings screen nobody reads. What a user needs HERE is the answer to "is it
289
+ * on, and how many" — the discoverable control is `/retry`, which this row names.
290
+ */
291
+ if (retrySummary) {
292
+ rows.push(_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', 'API retries'.padEnd(18), retrySummary] }, "__retry"));
293
+ }
148
294
  rows.push(_jsx(Text, { wrap: "truncate", color: theme.muted, children: "Enter saves. Keys persist to the config file (0600)." }, "__save"));
149
295
  return (_jsx(OverlayFrame, { title: "Settings", hint: `${glyphs.arrowUp}${glyphs.arrowDown} field ${glyphs.midDot} ${glyphs.arrowLeft}${glyphs.arrowRight} change ${glyphs.midDot} Enter save ${glyphs.midDot} Esc close`, maxRows: maxRows, cols: cols, rows: rows, scrollOffset: scrollOffset, onScrollClamp: onScrollClamp, theme: theme, caps: caps }));
150
296
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Render caches (tui-render-performance L2 / R2 + R3).
3
+ *
4
+ * Ink has no output caching: every frame re-walks the whole mounted tree,
5
+ * re-measures every text node and re-serialises the frame. The two most
6
+ * expensive PURE computations in that walk were being redone from scratch on
7
+ * every one of them:
8
+ *
9
+ * - `cli-highlight`'s `highlight()` ran INSIDE `CodeBlock`'s render body, so a
10
+ * 200-line fenced block was a complete highlight.js tokenise, 30 times a
11
+ * second, for as long as it stayed mounted;
12
+ * - `Markdown` re-split and re-regexed the whole answer into one React element
13
+ * per line on every render.
14
+ *
15
+ * Everything cached here is a function of its INPUT STRING ALONE. Theme and
16
+ * glyphs are applied at render time and are never cached, because a themed
17
+ * React element in a cache makes `/theme` a no-op until the entry evicts (K-5) —
18
+ * exactly the class of silent bug this package writes comments to avoid.
19
+ *
20
+ * Caches are MODULE-LEVEL on purpose: settled transcript text is identical
21
+ * across component instances and across mount/unmount cycles, so per-instance
22
+ * caches would miss precisely when a scroll brings an old entry back.
23
+ */
24
+ import { type MdBlock } from './markdown-blocks.js';
25
+ import { type Glyphs } from './glyphs.js';
26
+ import type { TermCapabilities } from './capabilities.js';
27
+ export declare const RENDER_CACHE_LIMITS: {
28
+ readonly highlightEntries: 128;
29
+ readonly highlightBytes: number;
30
+ /**
31
+ * Above this, skip highlighting entirely.
32
+ *
33
+ * highlight.js is superlinear in the length of a single block, and a
34
+ * multi-megabyte "code block" is almost always a pasted log rather than
35
+ * source. Rendering it plain costs the user nothing they would have been able
36
+ * to read; tokenising it costs the whole frame budget.
37
+ */
38
+ readonly highlightMaxChars: 20000;
39
+ readonly markdownEntries: 256;
40
+ readonly markdownBytes: number;
41
+ readonly linesEntries: 512;
42
+ };
43
+ export interface RenderCacheStats {
44
+ markdownEntries: number;
45
+ markdownBytes: number;
46
+ highlightEntries: number;
47
+ highlightBytes: number;
48
+ lineEntries: number;
49
+ hits: number;
50
+ misses: number;
51
+ }
52
+ /**
53
+ * Syntax-highlight `code`, memoised on `(lang, code)`.
54
+ *
55
+ * `cli-highlight` emits chalk-level ANSI that does not depend on `theme.*`, so
56
+ * unlike a rendered element this string IS safe to cache across a `/theme`
57
+ * switch — the same property it has had since before this cache existed.
58
+ */
59
+ export declare function highlightCached(code: string, lang: string): string;
60
+ /** Parse a markdown document into blocks, memoised on the whole text. */
61
+ export declare function parseMarkdownCached(text: string): MdBlock[];
62
+ /**
63
+ * `text.split('\n')`, memoised.
64
+ *
65
+ * READ-ONLY BY CONTRACT. The returned array is shared with every other holder of
66
+ * the same string, so a caller that sorts or splices it in place corrupts an
67
+ * unrelated card. The `readonly string[]` return type is the enforcement.
68
+ */
69
+ export declare function splitLinesCached(text: string): readonly string[];
70
+ /**
71
+ * The glyph set for a terminal.
72
+ *
73
+ * `pickGlyphs` already returns one of two module constants, so this adds no
74
+ * caching of its own — it exists so that every render-layer memoisation the
75
+ * transcript depends on is reachable from one module, and so the components can
76
+ * stop calling `pickGlyphs` once per entry per frame through six import paths.
77
+ */
78
+ export declare function glyphsFor(caps: TermCapabilities): Glyphs;
79
+ /** Drop everything. `/perf reset`, and the test suite between cases. */
80
+ export declare function clearRenderCaches(): void;
81
+ export declare function renderCacheStats(): RenderCacheStats;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Render caches (tui-render-performance L2 / R2 + R3).
3
+ *
4
+ * Ink has no output caching: every frame re-walks the whole mounted tree,
5
+ * re-measures every text node and re-serialises the frame. The two most
6
+ * expensive PURE computations in that walk were being redone from scratch on
7
+ * every one of them:
8
+ *
9
+ * - `cli-highlight`'s `highlight()` ran INSIDE `CodeBlock`'s render body, so a
10
+ * 200-line fenced block was a complete highlight.js tokenise, 30 times a
11
+ * second, for as long as it stayed mounted;
12
+ * - `Markdown` re-split and re-regexed the whole answer into one React element
13
+ * per line on every render.
14
+ *
15
+ * Everything cached here is a function of its INPUT STRING ALONE. Theme and
16
+ * glyphs are applied at render time and are never cached, because a themed
17
+ * React element in a cache makes `/theme` a no-op until the entry evicts (K-5) —
18
+ * exactly the class of silent bug this package writes comments to avoid.
19
+ *
20
+ * Caches are MODULE-LEVEL on purpose: settled transcript text is identical
21
+ * across component instances and across mount/unmount cycles, so per-instance
22
+ * caches would miss precisely when a scroll brings an old entry back.
23
+ */
24
+ import { highlight } from 'cli-highlight';
25
+ import { parseMarkdownBlocks } from './markdown-blocks.js';
26
+ import { pickGlyphs } from './glyphs.js';
27
+ export const RENDER_CACHE_LIMITS = {
28
+ highlightEntries: 128,
29
+ highlightBytes: 2 * 1024 * 1024,
30
+ /**
31
+ * Above this, skip highlighting entirely.
32
+ *
33
+ * highlight.js is superlinear in the length of a single block, and a
34
+ * multi-megabyte "code block" is almost always a pasted log rather than
35
+ * source. Rendering it plain costs the user nothing they would have been able
36
+ * to read; tokenising it costs the whole frame budget.
37
+ */
38
+ highlightMaxChars: 20_000,
39
+ markdownEntries: 256,
40
+ markdownBytes: 4 * 1024 * 1024,
41
+ linesEntries: 512,
42
+ };
43
+ /**
44
+ * Insertion-ordered LRU with both an entry-count and an approximate byte budget.
45
+ *
46
+ * TWO BUDGETS, NOT ONE. An entry count alone is unbounded in memory (one 5 MB
47
+ * answer), and a byte budget alone is unbounded in Map overhead (a hundred
48
+ * thousand one-character keys). Every cache here can be fed by the model, so
49
+ * both directions have to be closed.
50
+ */
51
+ class LruCache {
52
+ maxEntries;
53
+ maxBytes;
54
+ map = new Map();
55
+ total = 0;
56
+ hits = 0;
57
+ misses = 0;
58
+ constructor(maxEntries, maxBytes) {
59
+ this.maxEntries = maxEntries;
60
+ this.maxBytes = maxBytes;
61
+ }
62
+ get(key) {
63
+ const hit = this.map.get(key);
64
+ if (hit === undefined) {
65
+ this.misses += 1;
66
+ return undefined;
67
+ }
68
+ // Re-insert to move the key to the young end of the insertion order.
69
+ this.map.delete(key);
70
+ this.map.set(key, hit);
71
+ this.hits += 1;
72
+ return hit.value;
73
+ }
74
+ set(key, value, bytes) {
75
+ const existing = this.map.get(key);
76
+ if (existing)
77
+ this.total -= existing.bytes;
78
+ this.map.delete(key);
79
+ this.map.set(key, { value, bytes });
80
+ this.total += bytes;
81
+ while (this.map.size > this.maxEntries || this.total > this.maxBytes) {
82
+ const oldest = this.map.keys().next();
83
+ if (oldest.done)
84
+ break;
85
+ const entry = this.map.get(oldest.value);
86
+ if (entry)
87
+ this.total -= entry.bytes;
88
+ this.map.delete(oldest.value);
89
+ }
90
+ return value;
91
+ }
92
+ clear() {
93
+ this.map.clear();
94
+ this.total = 0;
95
+ this.hits = 0;
96
+ this.misses = 0;
97
+ }
98
+ get size() {
99
+ return this.map.size;
100
+ }
101
+ get bytes() {
102
+ return this.total;
103
+ }
104
+ }
105
+ const highlightCache = new LruCache(RENDER_CACHE_LIMITS.highlightEntries, RENDER_CACHE_LIMITS.highlightBytes);
106
+ const markdownCache = new LruCache(RENDER_CACHE_LIMITS.markdownEntries, RENDER_CACHE_LIMITS.markdownBytes);
107
+ /**
108
+ * `text.split('\n')` results.
109
+ *
110
+ * Bounded by ENTRY COUNT ONLY, and the byte budget is set to the markdown one
111
+ * because the arrays are views over strings the transcript already holds — they
112
+ * add pointer overhead, not a second copy of the text.
113
+ */
114
+ const lineCache = new LruCache(RENDER_CACHE_LIMITS.linesEntries, RENDER_CACHE_LIMITS.markdownBytes);
115
+ /**
116
+ * Syntax-highlight `code`, memoised on `(lang, code)`.
117
+ *
118
+ * `cli-highlight` emits chalk-level ANSI that does not depend on `theme.*`, so
119
+ * unlike a rendered element this string IS safe to cache across a `/theme`
120
+ * switch — the same property it has had since before this cache existed.
121
+ */
122
+ export function highlightCached(code, lang) {
123
+ if (code.length === 0)
124
+ return code;
125
+ if (code.length > RENDER_CACHE_LIMITS.highlightMaxChars)
126
+ return code;
127
+ // The delimiter is written as an ESCAPE and never as a literal control
128
+ // character: a raw NUL byte makes the whole file "binary" to grep and
129
+ // ripgrep, which silently hides every definition in it from the tool this
130
+ // codebase navigates itself with. NUL rather than a space because a space
131
+ // still lets ("a b", "ts") and ("b", "ts a") collide on one key.
132
+ const key = `${lang}\u0000${code}`;
133
+ const hit = highlightCache.get(key);
134
+ if (hit !== undefined)
135
+ return hit;
136
+ let rendered = code;
137
+ try {
138
+ rendered = highlight(code, { language: lang || undefined, ignoreIllegals: true });
139
+ }
140
+ catch {
141
+ rendered = code;
142
+ }
143
+ return highlightCache.set(key, rendered, key.length + rendered.length);
144
+ }
145
+ /** Parse a markdown document into blocks, memoised on the whole text. */
146
+ export function parseMarkdownCached(text) {
147
+ const hit = markdownCache.get(text);
148
+ if (hit !== undefined)
149
+ return hit;
150
+ return markdownCache.set(text, parseMarkdownBlocks(text), text.length * 2);
151
+ }
152
+ /**
153
+ * `text.split('\n')`, memoised.
154
+ *
155
+ * READ-ONLY BY CONTRACT. The returned array is shared with every other holder of
156
+ * the same string, so a caller that sorts or splices it in place corrupts an
157
+ * unrelated card. The `readonly string[]` return type is the enforcement.
158
+ */
159
+ export function splitLinesCached(text) {
160
+ if (text.length === 0)
161
+ return EMPTY_LINES;
162
+ const hit = lineCache.get(text);
163
+ if (hit !== undefined)
164
+ return hit;
165
+ return lineCache.set(text, text.split('\n'), text.length);
166
+ }
167
+ const EMPTY_LINES = [];
168
+ /**
169
+ * The glyph set for a terminal.
170
+ *
171
+ * `pickGlyphs` already returns one of two module constants, so this adds no
172
+ * caching of its own — it exists so that every render-layer memoisation the
173
+ * transcript depends on is reachable from one module, and so the components can
174
+ * stop calling `pickGlyphs` once per entry per frame through six import paths.
175
+ */
176
+ export function glyphsFor(caps) {
177
+ return pickGlyphs(caps);
178
+ }
179
+ /** Drop everything. `/perf reset`, and the test suite between cases. */
180
+ export function clearRenderCaches() {
181
+ highlightCache.clear();
182
+ markdownCache.clear();
183
+ lineCache.clear();
184
+ }
185
+ export function renderCacheStats() {
186
+ return {
187
+ markdownEntries: markdownCache.size,
188
+ markdownBytes: markdownCache.bytes,
189
+ highlightEntries: highlightCache.size,
190
+ highlightBytes: highlightCache.bytes,
191
+ lineEntries: lineCache.size,
192
+ hits: markdownCache.hits + highlightCache.hits + lineCache.hits,
193
+ misses: markdownCache.misses + highlightCache.misses + lineCache.misses,
194
+ };
195
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Adaptive render governor (tui-render-performance L4 / R7). Pure, React-free.
3
+ *
4
+ * Neither the streaming coalescer nor Ink adapts when a frame overruns its
5
+ * budget: the coalescer keeps its 33 ms timer, Ink keeps its 32 ms throttle, and
6
+ * the event loop saturates. Renders then queue behind each other on Node's
7
+ * single thread and KEYSTROKES QUEUE BEHIND THE RENDERS — which is what turns a
8
+ * slow frame into an unresponsive terminal.
9
+ *
10
+ * The ladder is the safety net under L1/L2/L3, not a substitute for them: it
11
+ * cannot make a frame cheaper, only rarer. Its job is to turn any residual
12
+ * pathology from a freeze into a visibly slower but still interactive UI.
13
+ *
14
+ * I-L4-1 — the governor changes only HOW OFTEN the view updates. Never what is
15
+ * dispatched, never the order, never the final state. `mergeDeltas` already
16
+ * guarantees that a longer window is simply a larger merge and that any
17
+ * non-delta action flushes the pending run first, so the same event sequence
18
+ * must produce the same final `ViewState` at every rung
19
+ * (`render-governor.test.ts`).
20
+ */
21
+ /** Coalescing intervals, in ms. Rung 0 is the pre-feature `COALESCE_MS`. */
22
+ export declare const GOVERNOR_LADDER: readonly [33, 50, 80, 125, 200, 320];
23
+ /** Step up when a commit costs more than this fraction of the current interval. */
24
+ export declare const GOVERNOR_UP_RATIO = 0.7;
25
+ /**
26
+ * Consecutive cheap frames required before stepping back down.
27
+ *
28
+ * Hysteresis, and it has to be asymmetric: stepping up on one expensive frame
29
+ * and down on one cheap one would oscillate through the whole ladder while a
30
+ * long answer streams past a couple of code blocks.
31
+ */
32
+ export declare const GOVERNOR_DOWN_STREAK = 8;
33
+ export interface GovernorState {
34
+ rung: number;
35
+ cheapStreak: number;
36
+ }
37
+ export declare function initialGovernor(): GovernorState;
38
+ /**
39
+ * The coalescing interval for a state.
40
+ *
41
+ * Clamped to `maxIntervalMs` in BOTH directions: `maxRenderIntervalMs = 33`
42
+ * flattens the ladder to one rung, which is the documented way to get the
43
+ * pre-feature cadence without reaching for the kill switch.
44
+ */
45
+ export declare function intervalOf(state: GovernorState, maxIntervalMs: number): number;
46
+ /**
47
+ * Fold one commit's cost into the ladder.
48
+ *
49
+ * `lastCommitMs` covers React reconciliation AND Ink's yoga layout, because Ink
50
+ * computes layout in `onRender`, which React runs before layout effects. It does
51
+ * NOT cover Ink's serialisation and the stdout write, which happen after. In
52
+ * practice layout dominates, and the ladder's job is DIRECTION, not accuracy.
53
+ */
54
+ export declare function stepGovernor(state: GovernorState, lastCommitMs: number, maxIntervalMs: number): GovernorState;