@aragon-agent/cli 0.5.8 → 0.5.10

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 +1157 -747
  2. package/README.md +1552 -1163
  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/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +40 -0
  9. package/dist/agent/reducer.d.ts +245 -3
  10. package/dist/agent/reducer.js +490 -17
  11. package/dist/agent/retry-view.d.ts +68 -0
  12. package/dist/agent/retry-view.js +95 -0
  13. package/dist/agent/system-prompt.d.ts +11 -0
  14. package/dist/agent/system-prompt.js +2 -0
  15. package/dist/boot/guard.d.ts +135 -0
  16. package/dist/boot/guard.js +218 -0
  17. package/dist/boot/rollback.d.ts +86 -0
  18. package/dist/boot/rollback.js +185 -0
  19. package/dist/cli.js +368 -3
  20. package/dist/commands/builtins.d.ts +1 -1
  21. package/dist/commands/builtins.js +426 -2
  22. package/dist/commands/registry.d.ts +17 -0
  23. package/dist/config/app-paths.d.ts +31 -0
  24. package/dist/config/app-paths.js +37 -0
  25. package/dist/config/cli-commands.d.ts +79 -2
  26. package/dist/config/cli-commands.js +185 -3
  27. package/dist/config/env.d.ts +15 -3
  28. package/dist/config/env.js +143 -3
  29. package/dist/config/load.d.ts +66 -0
  30. package/dist/config/load.js +107 -1
  31. package/dist/config/schema.d.ts +268 -2
  32. package/dist/config/schema.js +288 -1
  33. package/dist/config/store.js +32 -1
  34. package/dist/fast/critique.d.ts +43 -0
  35. package/dist/fast/critique.js +62 -0
  36. package/dist/fast/digest.d.ts +42 -0
  37. package/dist/fast/digest.js +85 -0
  38. package/dist/fast/frames.d.ts +99 -0
  39. package/dist/fast/frames.js +180 -0
  40. package/dist/fast/limits.d.ts +98 -0
  41. package/dist/fast/limits.js +98 -0
  42. package/dist/fast/prompt.d.ts +72 -0
  43. package/dist/fast/prompt.js +94 -0
  44. package/dist/fast/resolve.d.ts +43 -0
  45. package/dist/fast/resolve.js +105 -0
  46. package/dist/fast/review-call.d.ts +99 -0
  47. package/dist/fast/review-call.js +130 -0
  48. package/dist/fast/reviewer.d.ts +248 -0
  49. package/dist/fast/reviewer.js +664 -0
  50. package/dist/fast/types.d.ts +128 -0
  51. package/dist/fast/types.js +14 -0
  52. package/dist/fast/wiring.d.ts +170 -0
  53. package/dist/fast/wiring.js +316 -0
  54. package/dist/launcher.d.ts +14 -0
  55. package/dist/launcher.js +43 -0
  56. package/dist/logging/install.d.ts +15 -1
  57. package/dist/logging/install.js +37 -4
  58. package/dist/logging/logger.d.ts +1 -1
  59. package/dist/session/persist.js +57 -1
  60. package/dist/team/normalize.d.ts +26 -1
  61. package/dist/team/normalize.js +23 -3
  62. package/dist/team/prompt.d.ts +11 -0
  63. package/dist/team/prompt.js +10 -0
  64. package/dist/team/report.d.ts +18 -0
  65. package/dist/team/report.js +27 -1
  66. package/dist/team/runtime.d.ts +13 -2
  67. package/dist/team/runtime.js +33 -6
  68. package/dist/team/subagent.d.ts +19 -1
  69. package/dist/team/subagent.js +72 -10
  70. package/dist/team/task-tool.d.ts +26 -0
  71. package/dist/team/task-tool.js +48 -4
  72. package/dist/team/types.d.ts +80 -1
  73. package/dist/tools/bash-tool.js +19 -3
  74. package/dist/tools/diff.d.ts +25 -5
  75. package/dist/tools/diff.js +72 -41
  76. package/dist/tools/file-change-store.d.ts +34 -0
  77. package/dist/tools/file-change-store.js +57 -0
  78. package/dist/tools/fs-tools.d.ts +26 -0
  79. package/dist/tools/fs-tools.js +62 -5
  80. package/dist/tools/index.d.ts +31 -0
  81. package/dist/tools/index.js +7 -1
  82. package/dist/tools/patch.d.ts +115 -0
  83. package/dist/tools/patch.js +453 -0
  84. package/dist/tools/terminal-output.d.ts +107 -0
  85. package/dist/tools/terminal-output.js +198 -0
  86. package/dist/tools/tool-output-store.d.ts +68 -0
  87. package/dist/tools/tool-output-store.js +102 -0
  88. package/dist/ui/ActivityLine.d.ts +48 -0
  89. package/dist/ui/ActivityLine.js +22 -0
  90. package/dist/ui/App.d.ts +11 -4
  91. package/dist/ui/App.js +359 -37
  92. package/dist/ui/BottomStatusRow.d.ts +65 -0
  93. package/dist/ui/BottomStatusRow.js +17 -0
  94. package/dist/ui/Composer.d.ts +1 -6
  95. package/dist/ui/Composer.js +2 -2
  96. package/dist/ui/PromptInput.d.ts +1 -15
  97. package/dist/ui/PromptInput.js +1 -38
  98. package/dist/ui/StatusBar.d.ts +37 -0
  99. package/dist/ui/StatusBar.js +5 -3
  100. package/dist/ui/TeamPanel.d.ts +5 -4
  101. package/dist/ui/TeamPanel.js +1 -1
  102. package/dist/ui/TodoPanel.d.ts +5 -4
  103. package/dist/ui/Transcript.d.ts +26 -3
  104. package/dist/ui/Transcript.js +76 -10
  105. package/dist/ui/UpdateLine.d.ts +44 -0
  106. package/dist/ui/UpdateLine.js +129 -0
  107. package/dist/ui/activity-phrases.d.ts +33 -0
  108. package/dist/ui/activity-phrases.js +50 -0
  109. package/dist/ui/entries/AssistantEntry.d.ts +20 -0
  110. package/dist/ui/entries/AssistantEntry.js +3 -2
  111. package/dist/ui/entries/DiffView.d.ts +67 -0
  112. package/dist/ui/entries/DiffView.js +103 -0
  113. package/dist/ui/entries/FastCard.d.ts +53 -0
  114. package/dist/ui/entries/FastCard.js +87 -0
  115. package/dist/ui/entries/RetryCard.d.ts +56 -0
  116. package/dist/ui/entries/RetryCard.js +60 -0
  117. package/dist/ui/entries/TeamCard.js +6 -1
  118. package/dist/ui/entries/ToolCard.d.ts +38 -1
  119. package/dist/ui/entries/ToolCard.js +68 -5
  120. package/dist/ui/glyphs.d.ts +9 -0
  121. package/dist/ui/glyphs.js +2 -0
  122. package/dist/ui/layout/AppShell.d.ts +13 -22
  123. package/dist/ui/layout/AppShell.js +3 -38
  124. package/dist/ui/layout/virtual-window.d.ts +1 -1
  125. package/dist/ui/layout/virtual-window.js +109 -5
  126. package/dist/ui/overlays/HelpOverlay.js +16 -2
  127. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  128. package/dist/ui/overlays/SettingsScreen.js +147 -1
  129. package/dist/ui/transcript-text.js +27 -0
  130. package/dist/ui/use-wheel-routing.d.ts +16 -32
  131. package/dist/ui/use-wheel-routing.js +21 -38
  132. package/dist/update/classify-failure.d.ts +32 -0
  133. package/dist/update/classify-failure.js +60 -0
  134. package/dist/update/install-lock.d.ts +55 -0
  135. package/dist/update/install-lock.js +184 -0
  136. package/dist/update/install-source.d.ts +97 -0
  137. package/dist/update/install-source.js +218 -0
  138. package/dist/update/installer.d.ts +108 -0
  139. package/dist/update/installer.js +242 -0
  140. package/dist/update/limits.d.ts +111 -0
  141. package/dist/update/limits.js +111 -0
  142. package/dist/update/npm-view.d.ts +62 -0
  143. package/dist/update/npm-view.js +133 -0
  144. package/dist/update/registry.d.ts +52 -0
  145. package/dist/update/registry.js +161 -0
  146. package/dist/update/semver.d.ts +89 -0
  147. package/dist/update/semver.js +295 -0
  148. package/dist/update/service.d.ts +193 -0
  149. package/dist/update/service.js +636 -0
  150. package/dist/update/state.d.ts +103 -0
  151. package/dist/update/state.js +142 -0
  152. package/dist/update/types.d.ts +169 -0
  153. package/dist/update/types.js +68 -0
  154. package/package.json +3 -3
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * RetryCard — the one-line retry entry (llm-api-retry-backoff §6.5).
4
+ *
5
+ * ASCII ONLY: every glyph comes from `pickGlyphs`, and the line itself is built by
6
+ * the pure `formatRetryLine` in `agent/retry-view.ts` so it can be asserted
7
+ * without a terminal.
8
+ *
9
+ * ONE LINE. ALWAYS ONE LINE. Ten retries rendered as ten notices would bury the
10
+ * transcript in the one moment the user most needs it legible, so this card is
11
+ * rewritten in place for the whole turn and settles into a single row of history.
12
+ * Amber while in flight, muted once recovered, red once the ladder is exhausted.
13
+ */
14
+ import React from 'react';
15
+ import { Box, Text } from 'ink';
16
+ import Spinner from 'ink-spinner';
17
+ import { pickGlyphs } from '../glyphs.js';
18
+ import { formatRetryLine } from '../../agent/retry-view.js';
19
+ /**
20
+ * The card's tone. `exhausted` is the only failure: an `interrupted` card records
21
+ * a user decision, and colouring that red would blame them for it.
22
+ */
23
+ export function retryCardColor(phase, theme) {
24
+ if (phase === 'waiting' || phase === 'retrying')
25
+ return theme.noticeWarn;
26
+ if (phase === 'exhausted')
27
+ return theme.noticeError;
28
+ return theme.muted;
29
+ }
30
+ function RetryCardImpl(props) {
31
+ const { attempt, maxRetries, errorType, phase, resumeAt, totalRetries, elapsedMs, now,
32
+ // DEFAULTS TO TRUE: reaching this component means a TUI is mounted, and the
33
+ // TUI always binds Esc to abort. The prop exists so headless-shaped callers
34
+ // and the render tests can assert the suppressed form.
35
+ interactive = true, reducedMotion, theme, caps, } = props;
36
+ const glyphs = pickGlyphs(caps);
37
+ const color = retryCardColor(phase, theme);
38
+ const inFlight = phase === 'waiting' || phase === 'retrying';
39
+ const line = formatRetryLine({
40
+ attempt,
41
+ maxRetries,
42
+ errorType,
43
+ phase,
44
+ ...(resumeAt !== undefined ? { resumeAt } : {}),
45
+ ...(totalRetries !== undefined ? { totalRetries } : {}),
46
+ ...(elapsedMs !== undefined ? { elapsedMs } : {}),
47
+ }, glyphs, now);
48
+ // REDUCED MOTION SUPPRESSES THE SPINNER AND KEEPS THE COUNTDOWN. A frozen number
49
+ // is not calmer, it is broken: reduced motion is about animation, and a
50
+ // countdown is information.
51
+ const animate = inFlight && !reducedMotion && caps.unicode;
52
+ return (_jsxs(Box, { flexDirection: "row", children: [animate ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), ' '] })) : null, _jsx(Text, { color: color, wrap: "truncate", children: line }), phase === 'waiting' && interactive && (_jsxs(Text, { color: theme.muted, children: [' ', "Esc to cancel"] }))] }));
53
+ }
54
+ /**
55
+ * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
56
+ * `mapEntry` preserves object identity for untouched entries, and `theme` / `caps`
57
+ * are `useMemo`d in `App.tsx` (I-L2-1). `now` changes once a second while the card
58
+ * is waiting, which is exactly when it must re-render.
59
+ */
60
+ export const RetryCard = React.memo(RetryCardImpl);
@@ -48,7 +48,12 @@ function TeamCardImpl(props) {
48
48
  : `${formatDuration(durationMs ?? 0)} (${ok} ok, ${failed} failed)`;
49
49
  const count = `${runs.length} ${runs.length === 1 ? 'subagent' : 'subagents'}`;
50
50
  const dropped = requested > runs.length ? ` (${runs.length} of ${requested} requested)` : '';
51
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "team" }), _jsxs(Text, { color: theme.muted, children: [' ', count, dropped, ' '] }), active && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " ", headline] })) : (_jsxs(Text, { color: color, children: [active ? `${glyphs.spinnerStill} ` : '', headline] }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [runs.map((run) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: isOk(run) ? theme.toolDone : theme.toolError, children: isOk(run) ? glyphs.toolDone : glyphs.toolError }), _jsxs(Text, { color: theme.primary, children: [" ", run.label.padEnd(8).slice(0, 8)] }), _jsxs(Text, { color: theme.muted, children: [' ', run.description, ' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? 0) - run.startedAt)) : '', ` ${run.turns} turns`, run.filesTouched.length > 0 ? ` ${run.filesTouched.length} files` : '', run.error ? ` ${run.error}` : ''] })] }, run.label))), expanded &&
51
+ // ABSENT WHEN NO FAST CHILD RAN, so an ordinary dispatch's card is unchanged
52
+ // (fast-model-tier §6). `n fast` rather than a ratio: the total is already one
53
+ // column to the left.
54
+ const fastCount = runs.filter((r) => r.tier === 'fast').length;
55
+ const fastNote = fastCount > 0 ? ` ${fastCount} fast` : '';
56
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "team" }), _jsxs(Text, { color: theme.muted, children: [' ', count, dropped, fastNote, ' '] }), active && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " ", headline] })) : (_jsxs(Text, { color: color, children: [active ? `${glyphs.spinnerStill} ` : '', headline] }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [runs.map((run) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: isOk(run) ? theme.toolDone : theme.toolError, children: isOk(run) ? glyphs.toolDone : glyphs.toolError }), _jsxs(Text, { color: theme.primary, children: [' ', `${run.label}${run.tier === 'fast' ? '~' : ''}`.padEnd(8).slice(0, 8)] }), _jsxs(Text, { color: theme.muted, children: [' ', run.description, ' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? 0) - run.startedAt)) : '', ` ${run.turns} turns`, run.filesTouched.length > 0 ? ` ${run.filesTouched.length} files` : '', run.error ? ` ${run.error}` : ''] }), run.retry && (_jsxs(Text, { color: theme.noticeWarn, bold: true, children: [' ', "retry ", run.retry.attempt, "/", run.retry.maxRetries] }))] }, run.label))), expanded &&
52
57
  runs.map((run) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.accent, children: [run.label, " ", glyphs.midDot, " ", run.description] }), _jsx(Text, { color: theme.muted, children: run.summary ?? '(no summary)' })] }, `s-${run.label}`))), !expanded && !active && runs.length > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", runs.length, " ", runs.length === 1 ? 'summary' : 'summaries', " (Ctrl+O)"] })), expanded && _jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" })] })] }));
53
58
  }
54
59
  /**
@@ -17,6 +17,7 @@ import React from 'react';
17
17
  import type { Theme } from '../theme.js';
18
18
  import type { TermCapabilities } from '../capabilities.js';
19
19
  import type { ToolStatus } from '../../agent/reducer.js';
20
+ import type { FilePatch } from '../../tools/patch.js';
20
21
  interface ToolCardProps {
21
22
  name: string;
22
23
  label: string;
@@ -24,6 +25,14 @@ interface ToolCardProps {
24
25
  args?: Record<string, unknown>;
25
26
  status: ToolStatus;
26
27
  preview?: string;
28
+ /**
29
+ * The structured file diff, when the tool produced one (§3.4). Present it and
30
+ * `DiffView` draws INSTEAD of `ToolPreview`; absent — a session saved by an
31
+ * older build, an entry the store's 64-write ring evicted, or a toolset built
32
+ * without a recorder — and the text preview renders exactly what it does
33
+ * today. Nothing ever renders blank.
34
+ */
35
+ patch?: FilePatch;
27
36
  durationMs?: number;
28
37
  isError?: boolean;
29
38
  expanded?: boolean;
@@ -34,6 +43,28 @@ interface ToolCardProps {
34
43
  * means no clamp and byte-identical output.
35
44
  */
36
45
  liveClampRows?: number;
46
+ /**
47
+ * The sanitised live tail while the tool runs
48
+ * (agent-activity-presentation-live §3.3.4).
49
+ *
50
+ * PASSED AS `live={entry.live}` AND NEVER `live={entry.live ?? []}` (P2-4).
51
+ * This component is `React.memo` with the DEFAULT comparator, so a fresh `[]`
52
+ * per render is a changed prop and defeats the boundary for every settled card
53
+ * in the transcript — which is exactly why `EMPTY_LINES` above exists.
54
+ */
55
+ live?: readonly string[];
56
+ /** Epoch ms of the last output row; with `nowSec`, the stall row's clock. */
57
+ lastOutputAt?: number;
58
+ /**
59
+ * Wall-clock SECONDS, passed ONLY to running entries (D-29 / D-36).
60
+ *
61
+ * Seconds and not milliseconds, and `undefined` on every other card, for the
62
+ * same reason: a prop that changed five times a second on every entry would
63
+ * defeat this memo boundary transcript-wide. The value is wall-clock rather
64
+ * than the ticker's `elapsedMs` because the stall is `now - lastOutputAt` and
65
+ * `lastOutputAt` is epoch ms; the ticker's job is to cause the render.
66
+ */
67
+ nowSec?: number;
37
68
  theme: Theme;
38
69
  caps: TermCapabilities;
39
70
  }
@@ -44,7 +75,13 @@ declare function ToolCardImpl(props: ToolCardProps): React.ReactElement;
44
75
  /**
45
76
  * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
46
77
  * Every prop is a scalar except `args` (a stable reference on a settled entry),
47
- * `theme` and `caps` (both `useMemo`d in `App.tsx` — I-L2-1).
78
+ * `patch` and `theme` / `caps` (both `useMemo`d in `App.tsx` — I-L2-1).
79
+ *
80
+ * `patch` QUALIFIES ONLY BECAUSE THE REDUCER WRITES IT ONCE, at `toolExecEnd`,
81
+ * and never rebuilds it — `mapEntry` preserves object identity for untouched
82
+ * entries. A future action that recomputed or normalized a patch would turn this
83
+ * boundary into a no-op WITH NOTHING FAILING, which is the same trap I-L2-1
84
+ * exists to catch for `theme` and `caps` (P2-9).
48
85
  */
49
86
  export declare const ToolCard: React.MemoExoticComponent<typeof ToolCardImpl>;
50
87
  export {};
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Tool-call card (spec §3.5): a per-tool glyph, the tool name, a compact args
4
4
  * summary, a colored status badge, and a duration, over a collapsible rich
@@ -22,6 +22,8 @@ import { railBorderProps } from '../layout/Gutter.js';
22
22
  import { splitLinesCached } from '../render-cache.js';
23
23
  import { formatDuration } from '../../agent/usage.js';
24
24
  import { ToolPreview, bashBadge } from './ToolPreview.js';
25
+ import { DiffView } from './DiffView.js';
26
+ import { STALL_AFTER_MS } from '../../tools/tool-output-store.js';
25
27
  /** Number of preview lines shown before collapse. */
26
28
  const COLLAPSED_LINES = 8;
27
29
  /** Shared empty slice, so an argument-less card allocates nothing per frame. */
@@ -78,7 +80,7 @@ export function toolEntryGlyph(name, caps) {
78
80
  return toolGlyph(pickGlyphs(caps), name);
79
81
  }
80
82
  function ToolCardImpl(props) {
81
- const { name, argsRaw, args, status, preview, durationMs, isError, expanded, reducedMotion, liveClampRows, theme, caps, } = props;
83
+ const { name, argsRaw, args, status, preview, patch, durationMs, isError, expanded, reducedMotion, liveClampRows, live, lastOutputAt, nowSec, theme, caps, } = props;
82
84
  const glyphs = pickGlyphs(caps);
83
85
  const color = statusColor(status, theme);
84
86
  const previewText = preview ?? '';
@@ -94,13 +96,74 @@ function ToolCardImpl(props) {
94
96
  const ceiling = Math.min(expanded ? previewLines.length : COLLAPSED_LINES, liveClampRows ?? Number.POSITIVE_INFINITY);
95
97
  const visibleLines = ceiling >= previewLines.length ? previewLines : previewLines.slice(0, ceiling);
96
98
  const hidden = previewLines.length - visibleLines.length;
97
- const showBody = previewLines.length > 0 && (status === 'done' || status === 'error');
99
+ const settled = status === 'done' || status === 'error';
100
+ const showPatch = !!patch && settled;
101
+ // The live tail (agent-activity-presentation-live §3.3.4). `expanded` is
102
+ // deliberately ABSENT from this arithmetic: `Ctrl+O` is inert on a live card
103
+ // (D-26), because expansion is a promise about STORED content and the store
104
+ // deliberately holds eight rows. A key that silently does nothing is better
105
+ // than a key that promises the rest of a 400 MB stream.
106
+ //
107
+ // THE TAIL IS THE **LAST** ROWS, and the settled preview above keeps its
108
+ // head-first slice (E-21). Two different ends because they answer two
109
+ // different questions — "what did this command do" versus "what is it doing
110
+ // NOW" (D-25). Head-first while live would freeze on the first eight lines a
111
+ // build ever printed and then never change again.
112
+ const liveRows = live ?? EMPTY_LINES;
113
+ const liveCeiling = Math.min(COLLAPSED_LINES,
114
+ // Inline only: `liveClampRows` already subtracts one row per live entry to
115
+ // pay for a marker (`Transcript.tsx:534-538`), which is what this card's
116
+ // unconditional footer spends (AC-36 / P2-7).
117
+ liveClampRows ?? Number.POSITIVE_INFINITY);
118
+ const visibleLive = liveRows.length > liveCeiling ? liveRows.slice(-Math.max(1, liveCeiling)) : liveRows;
119
+ const showLive = !settled && visibleLive.length > 0;
120
+ // THE OUTER GUARD WIDENS WITH THE BRANCH (P2-2). It was `previewLines.length >
121
+ // 0 && ...`, and the reducer now truncates the stored preview to ONE LINE when
122
+ // a patch is attached (D-19) — so a card whose preview were ever empty would
123
+ // render its header and swallow the diff, silently. It widens a second time
124
+ // here, for the third branch: without `|| showLive` the tail would be computed
125
+ // and never drawn.
126
+ const showBody = ((previewLines.length > 0 || showPatch) && settled) || showLive;
127
+ // ONE FOOTER ROW, ALWAYS, matching `estimateEntryRows`'s arithmetic exactly: a
128
+ // CONSTANT row count is what lets the estimate be exact rather than an upper
129
+ // bound. `nowSec` is absent until the first tick, which reads as "not stalled".
130
+ const stalledFor = nowSec !== undefined && lastOutputAt !== undefined
131
+ ? nowSec * 1000 - lastOutputAt
132
+ : 0;
133
+ const liveFooter = stalledFor >= STALL_AFTER_MS
134
+ ? `no output for ${Math.floor(stalledFor / 1000)}s`
135
+ : '(running)';
98
136
  const badge = status === 'running' && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " running"] })) : (_jsxs(Text, { color: color, children: [status === 'running' ? `${glyphs.spinnerStill} ` : '', badgeLabel(name, status, previewText)] }));
99
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: name }), _jsxs(Text, { color: theme.muted, children: [" ", summarizeArgs(name, glyphs.ellipsis, args, argsRaw), " "] }), badge, durationMs !== undefined && (_jsxs(Text, { color: theme.muted, children: [" (", formatDuration(durationMs), ")"] }))] }), showBody && (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, isError ? theme.toolError : theme.border), children: [_jsx(ToolPreview, { name: name, lines: visibleLines, theme: theme, isError: isError }), !expanded && hidden > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " (Ctrl+O)"] })), expanded && hidden > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " ", glyphs.midDot, " shown in full when this entry finishes"] })), expanded && previewLines.length > COLLAPSED_LINES && (_jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" }))] }))] }));
137
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: name }), _jsxs(Text, { color: theme.muted, children: [" ", summarizeArgs(name, glyphs.ellipsis, args, argsRaw), " "] }), badge, durationMs !== undefined && (_jsxs(Text, { color: theme.muted, children: [" (", formatDuration(durationMs), ")"] }))] }), showBody && (_jsx(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, isError ? theme.toolError : theme.border), children: showLive ? (_jsxs(_Fragment, { children: [visibleLive.map((row, i) => (
138
+ // The row is already terminal-safe and clipped at
139
+ // `LIVE_ROW_MAX_CHARS` by `sanitizeChunk`, so `wrap="truncate"`
140
+ // is an exact height rather than a hope — which is the coupling
141
+ // `estimateEntryRows`'s live branch relies on.
142
+ //
143
+ // The index IS the key: these rows are positional slots in a
144
+ // ring, not identities, and the whole array is replaced on every
145
+ // chunk anyway.
146
+ //
147
+ // AN EMPTY ROW RENDERS AS A SPACE, and that is not cosmetic:
148
+ // Ink measures a `<Text>` with an empty child as ZERO rows, so a
149
+ // blank separator line — which `npm test`, `cargo` and every
150
+ // build tool print — would make the card shorter than
151
+ // `estimateEntryRows` charged for it. That is an over-estimate,
152
+ // the safe direction, but it would quietly cost §3.3.4's
153
+ // "constant row count" the exactness its arithmetic claims. A
154
+ // space is also what the terminal itself shows for a blank line.
155
+ // eslint-disable-next-line react/no-array-index-key
156
+ _jsx(Text, { wrap: "truncate", color: theme.muted, children: row.length > 0 ? row : ' ' }, i))), _jsx(Text, { color: theme.muted, children: liveFooter })] })) : showPatch ? (_jsx(DiffView, { patch: patch, expanded: expanded, liveClampRows: liveClampRows, theme: theme })) : (_jsxs(_Fragment, { children: [_jsx(ToolPreview, { name: name, lines: visibleLines, theme: theme, isError: isError }), !expanded && hidden > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " (Ctrl+O)"] })), expanded && hidden > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", hidden, " ", hidden === 1 ? 'line' : 'lines', " ", glyphs.midDot, " shown in full when this entry finishes"] })), expanded && previewLines.length > COLLAPSED_LINES && (_jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" }))] })) }))] }));
100
157
  }
101
158
  /**
102
159
  * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
103
160
  * Every prop is a scalar except `args` (a stable reference on a settled entry),
104
- * `theme` and `caps` (both `useMemo`d in `App.tsx` — I-L2-1).
161
+ * `patch` and `theme` / `caps` (both `useMemo`d in `App.tsx` — I-L2-1).
162
+ *
163
+ * `patch` QUALIFIES ONLY BECAUSE THE REDUCER WRITES IT ONCE, at `toolExecEnd`,
164
+ * and never rebuilds it — `mapEntry` preserves object identity for untouched
165
+ * entries. A future action that recomputed or normalized a patch would turn this
166
+ * boundary into a no-op WITH NOTHING FAILING, which is the same trap I-L2-1
167
+ * exists to catch for `theme` and `caps` (P2-9).
105
168
  */
106
169
  export const ToolCard = React.memo(ToolCardImpl);
@@ -108,6 +108,15 @@ export interface Glyphs {
108
108
  todoPending: string;
109
109
  todoActive: string;
110
110
  todoDone: string;
111
+ /**
112
+ * API retry (llm-api-retry-backoff §6.5). The rail marker on the retry card.
113
+ *
114
+ * HERE RATHER THAN IN `RetryCard`, for the reason this file's header gives: a
115
+ * literal in a component bypasses `pickGlyphs` entirely and shows mojibake on a
116
+ * legacy console whatever the capability probe said. `glyphs.test.ts`'s static
117
+ * scan fails the build if either spelling appears anywhere else.
118
+ */
119
+ retry: string;
111
120
  /**
112
121
  * Ink `borderStyle` name for every boxed region (composer, completion popup,
113
122
  * overlay frame).
package/dist/ui/glyphs.js CHANGED
@@ -64,6 +64,7 @@ const UNICODE_GLYPHS = {
64
64
  todoPending: '○',
65
65
  todoActive: '▸',
66
66
  todoDone: '✔',
67
+ retry: '↻',
67
68
  tool: {
68
69
  read_file: '▤',
69
70
  write_file: '✎',
@@ -125,6 +126,7 @@ const ASCII_GLYPHS = {
125
126
  todoPending: '[ ]',
126
127
  todoActive: '>',
127
128
  todoDone: '[x]',
129
+ retry: '[r]',
128
130
  tool: {
129
131
  read_file: '=',
130
132
  write_file: '+',
@@ -11,7 +11,6 @@
11
11
  */
12
12
  import React from 'react';
13
13
  import { type RenderMode } from './frame.js';
14
- import { type FrameGeometry } from './regions.js';
15
14
  export interface AppShellProps {
16
15
  mode: RenderMode;
17
16
  rows: number;
@@ -21,11 +20,10 @@ export interface AppShellProps {
21
20
  /**
22
21
  * The live subagent roster (team-subagents §6.1), or nothing.
23
22
  *
24
- * IT RENDERS INSIDE `bottomRef` IN THE FULL-SCREEN BRANCH, and it has to
25
- * (I-10): `geometryRef.current.bottom` is MEASURED from that box, and the
26
- * wheel router uses it to decide whether a notch belongs to the transcript or
27
- * to prompt history. A panel rendered above the measured box would make the
28
- * composer band boundary stale the instant a dispatch started.
23
+ * IT RENDERS IN THE BOTTOM CHROME OF THE FULL-SCREEN BRANCH, above `toast` and
24
+ * below the viewport: the roster is transient status about the run in progress,
25
+ * so it belongs with the other transient rows rather than inside the scrolling
26
+ * transcript, where it would be interleaved with history and scroll away.
29
27
  */
30
28
  team?: React.ReactNode;
31
29
  /**
@@ -36,10 +34,11 @@ export interface AppShellProps {
36
34
  * primitive and has no business knowing what a todo is. `team` above already
37
35
  * leaks content vocabulary in here; there is no reason to repeat it.
38
36
  *
39
- * IT MUST NOT BE RENDERED INSIDE `bottomRef` (C-6 / I-4). That box is MEASURED
40
- * and the wheel router reads the composer band boundary from it; a column
41
- * added there would inflate the measurement and start routing transcript
42
- * scrolls into prompt history.
37
+ * IT MUST BE RENDERED IN THE MIDDLE BAND, BESIDE THE VIEWPORT (C-6 / I-4) —
38
+ * that is what makes it a rail rather than another row of bottom chrome. Moved
39
+ * down into the bottom box it would stop being a column beside the transcript
40
+ * and start eating viewport height, growing and shrinking the transcript every
41
+ * time the list changed length.
43
42
  *
44
43
  * The INLINE branch does not include it at all — a structural guarantee rather
45
44
  * than a promise that the caller passes `null` (non-goal 4).
@@ -56,21 +55,13 @@ export interface AppShellProps {
56
55
  * both on screen showing the same list (I-6). Fullscreen has the rail; that is
57
56
  * the whole design.
58
57
  *
59
- * It sits between `viewport` and `team`, i.e. OUTSIDE the measured box in the
60
- * other branch — but inline has no measured box at all (`geometryRef` is set
61
- * to `UNMEASURED_GEOMETRY` there), so there is nothing here to invalidate.
58
+ * It sits between `viewport` and `team`, mirroring where the rail sits in the
59
+ * other branch: last thing above the bottom chrome, so the strip and the
60
+ * roster read top-to-bottom in the same order in both modes.
62
61
  */
63
62
  strip?: React.ReactNode;
64
63
  toast: React.ReactNode;
65
64
  composer: React.ReactNode;
66
65
  status: React.ReactNode;
67
- /**
68
- * Where the wheel router reads the composer band boundary from (mouse-wheel
69
- * §4.5). Written in a LAYOUT EFFECT to a MUTABLE REF, never to state: the
70
- * measurement happens on every commit, and routing it through `setState`
71
- * would add a re-render to the streaming path for a value nothing renders
72
- * (R-7).
73
- */
74
- geometryRef?: React.MutableRefObject<FrameGeometry>;
75
66
  }
76
- export declare function AppShell({ mode, rows, cols, header, viewport, team, rail, strip, toast, composer, status, geometryRef, }: AppShellProps): React.ReactElement;
67
+ export declare function AppShell({ mode, rows, cols, header, viewport, team, rail, strip, toast, composer, status, }: AppShellProps): React.ReactElement;
@@ -1,44 +1,9 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- /**
3
- * AppShell (spec §4.3) — the frame that makes R2 true.
4
- *
5
- * In `fullscreen` the root box has a FIXED height of `frameHeight(rows)` and the
6
- * middle viewport is the only `flexGrow` child, so the bottom chrome (toast /
7
- * composer / status bar) is physically the last thing on the screen from the
8
- * very first frame — empty session, long session, mid-scroll, overlay open.
9
- *
10
- * In `inline` the same children are emitted in the same order with no height
11
- * constraint, i.e. byte-for-byte the v0.2.0 document-flow behavior.
12
- */
13
- import { useLayoutEffect, useRef } from 'react';
14
- import { Box, measureElement } from 'ink';
2
+ import { Box } from 'ink';
15
3
  import { frameHeight } from './frame.js';
16
- import { UNMEASURED_GEOMETRY } from './regions.js';
17
- export function AppShell({ mode, rows, cols, header, viewport, team, rail, strip, toast, composer, status, geometryRef, }) {
18
- const bottomRef = useRef(null);
19
- // DECLARED ABOVE THE INLINE EARLY RETURN, and it has to stay there: React
20
- // keys hooks by call order, and a hook that only runs in one mode changes
21
- // that order between modes.
22
- //
23
- // Ink computes yoga layout in `onRender`, which React runs BEFORE layout
24
- // effects, so this reflects the commit that just happened — the same
25
- // mechanism `ScrollViewport` already relies on.
26
- useLayoutEffect(() => {
27
- if (!geometryRef)
28
- return;
29
- if (mode === 'inline' || !bottomRef.current) {
30
- geometryRef.current = UNMEASURED_GEOMETRY;
31
- return;
32
- }
33
- geometryRef.current = {
34
- // 1 in the alternate screen — see the note on `FrameGeometry.top`.
35
- top: 1,
36
- height: frameHeight(rows),
37
- bottom: measureElement(bottomRef.current).height,
38
- };
39
- });
4
+ export function AppShell({ mode, rows, cols, header, viewport, team, rail, strip, toast, composer, status, }) {
40
5
  if (mode === 'inline') {
41
6
  return (_jsxs(Box, { flexDirection: "column", children: [header, viewport, strip, team, toast, composer, status] }));
42
7
  }
43
- return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), rail] }), _jsxs(Box, { ref: bottomRef, flexDirection: "column", flexShrink: 0, children: [team, toast, composer, status] })] }));
8
+ return (_jsxs(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexShrink: 0, children: header }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: viewport }), rail] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [team, toast, composer, status] })] }));
44
9
  }
@@ -73,7 +73,7 @@ export declare function heightKey(entry: Entry, cols: number, expanded: boolean,
73
73
  * after the entry is measured, while an under-estimate can put the newest output
74
74
  * off the bottom of a viewport the user believes is pinned.
75
75
  */
76
- export declare function estimateEntryRows(entry: Entry, cols: number, density: DensityMode, expanded: boolean): number;
76
+ export declare function estimateEntryRows(entry: Entry, cols: number, density: DensityMode, expanded: boolean, thinkingVisible?: boolean): number;
77
77
  export interface WindowSelection {
78
78
  /** First mounted entry, inclusive. */
79
79
  startIndex: number;
@@ -21,6 +21,7 @@
21
21
  * unchanged. No scroll-anchoring compensation pass is required. Do NOT
22
22
  * "simplify" `offset` to count from the top.
23
23
  */
24
+ import { FAST_LIMITS } from '../../fast/limits.js';
24
25
  import { GUTTER_WIDTH } from './Gutter.js';
25
26
  export const VIRTUAL_LIMITS = {
26
27
  /**
@@ -54,16 +55,52 @@ export function entryRevision(entry) {
54
55
  return 'u';
55
56
  case 'notice':
56
57
  return 'n';
58
+ // `thinkingMs` is O(1) and it CHANGES THE ROW: sealing the clock turns
59
+ // `* thought` into `* thought for 12s`, and with thinking hidden that row is
60
+ // the entry's whole rendered height.
57
61
  case 'assistant':
58
- return `a${entry.text.length}.${entry.thinking?.length ?? 0}.${entry.streaming ? 1 : 0}.${entry.aborted ? 1 : 0}`;
62
+ return `a${entry.text.length}.${entry.thinking?.length ?? 0}.${entry.streaming ? 1 : 0}.${entry.aborted ? 1 : 0}.${entry.thinkingMs ?? -1}`;
63
+ // `patch.lineCount` is PRECOMPUTED for exactly this term: summing
64
+ // `hunks[].lines.length` per entry per frame is the cost the height cache
65
+ // exists to remove, and I-L3-1 forbids hashing.
66
+ // `liveSeq` IS A COUNTER AND NOT `live.length`, and that is precisely the
67
+ // NON-APPEND mutation I-L3-1 above says must extend the revision: a
68
+ // fixed-size tail evicting its oldest row while appending a new one of the
69
+ // same width leaves the joined length unchanged, so a length term would go
70
+ // on matching while the card's content changed under it.
71
+ //
72
+ // SIX TERMS NOW, the longest revision in this file (P2-12). Still O(1) per
73
+ // entry per frame, which is the property I-L3-1 requires -- but the next
74
+ // field makes it seven, and a term that is not O(1) would put the cost this
75
+ // module exists to remove back on every frame.
59
76
  case 'tool':
60
- return `t${entry.status}.${entry.argsRaw.length}.${entry.preview?.length ?? 0}.${entry.durationMs ?? -1}`;
77
+ return `t${entry.status}.${entry.argsRaw.length}.${entry.preview?.length ?? 0}.${entry.durationMs ?? -1}.${entry.patch?.lineCount ?? -1}.${entry.liveSeq ?? -1}`;
61
78
  case 'team':
62
79
  return `m${entry.active ? 1 : 0}.${entry.runs.length}.${entry.runs
63
80
  .map((r) => r.phase[0])
64
81
  .join('')}.${entry.runs.reduce((acc, r) => acc + r.toolCalls, 0)}`;
65
82
  case 'todo':
66
83
  return `d${entry.live ? 1 : 0}.${entry.doneCount}.${entry.total}.${entry.items.length}`;
84
+ /**
85
+ * The retry card is ONE ROW IN EVERY PHASE, so its revision carries the phase
86
+ * and the counter but deliberately NOT the countdown: `resumeAt` changes the
87
+ * rendered SECONDS once a second while the height stays 1, and folding it in
88
+ * here would invalidate the height cache 30 times per backoff to re-measure a
89
+ * number that cannot change the answer.
90
+ *
91
+ * `phase` and `attempt` ARE in, because they change the row's text and a stale
92
+ * measurement would be wrong in a way nothing reports (I-L3-1).
93
+ */
94
+ case 'retry':
95
+ return `r${entry.phase}.${entry.attempt}.${entry.maxRetries}`;
96
+ /**
97
+ * `status`, `live` and the TEXT LENGTH, because all three change the rows the
98
+ * card draws: `advice` renders a body, everything else renders one line, and
99
+ * the body's length decides how many rows it wraps to. `durationMs` is in the
100
+ * header, which is `wrap="truncate"`, so it can never change the height.
101
+ */
102
+ case 'fast':
103
+ return `f${entry.status}.${entry.live ? 1 : 0}.${entry.text?.length ?? 0}.${entry.detail?.length ?? 0}`;
67
104
  default:
68
105
  return 'x';
69
106
  }
@@ -101,6 +138,10 @@ function wrappedRows(text, usable) {
101
138
  }
102
139
  /** Collapsed preview rows in `ToolCard`; duplicated as a NUMBER, not imported. */
103
140
  const TOOL_COLLAPSED_LINES = 8;
141
+ /** Collapsed diff rows in `DiffView`; duplicated for the same reason as above. */
142
+ const DIFF_COLLAPSED_LINES = 12;
143
+ /** Live tail rows in `ToolCard`; duplicated as a NUMBER, for the same reason. */
144
+ const TOOL_LIVE_TAIL_ROWS = 8;
104
145
  /**
105
146
  * Pure UPPER-BOUND estimate of an entry's height, used until a real measurement
106
147
  * exists.
@@ -110,7 +151,7 @@ const TOOL_COLLAPSED_LINES = 8;
110
151
  * after the entry is measured, while an under-estimate can put the newest output
111
152
  * off the bottom of a viewport the user believes is pinned.
112
153
  */
113
- export function estimateEntryRows(entry, cols, density, expanded) {
154
+ export function estimateEntryRows(entry, cols, density, expanded, thinkingVisible = true) {
114
155
  const usable = Math.max(1, cols - GUTTER_WIDTH);
115
156
  // `separationRows` needs the PRECEDING entry, which a per-entry estimate does
116
157
  // not have. One row is its maximum, so charging it unconditionally keeps this
@@ -122,12 +163,60 @@ export function estimateEntryRows(entry, cols, density, expanded) {
122
163
  case 'notice':
123
164
  return separation + wrappedRows(entry.text, usable);
124
165
  case 'assistant': {
125
- const thinking = entry.thinking ? 1 + wrappedRows(entry.thinking, usable) : 0;
166
+ // THE PARAMETER'S `true` DEFAULT IS A FAIL-SAFE FOR A FORGOTTEN CALLER,
167
+ // NOT THE PLAN FOR THE ONE THERE IS (P1-6a). `Transcript.tsx` holds
168
+ // `thinkingVisible` as a prop and MUST pass it: with thinking hidden by
169
+ // default, an unpassed flag inflates every assistant entry that thought by
170
+ // `1 + wrappedRows(thinking)` — hundreds of rows at `thinkingLevel: high`
171
+ // against a real height of one. The self-correction argument does not
172
+ // rescue that, because an entry is only measured once `selectWindow`
173
+ // mounts it, and an entry estimated at 400 rows is exactly the one that
174
+ // never gets mounted.
175
+ const thinking = !entry.thinking
176
+ ? 0
177
+ : thinkingVisible
178
+ ? 1 + wrappedRows(entry.thinking, usable)
179
+ : // The collapsed `thought for Ns` marker is exactly one row, and it is
180
+ // suppressed while the entry is still streaming (the activity line is
181
+ // the live surface then).
182
+ entry.streaming
183
+ ? 0
184
+ : 1;
126
185
  const body = entry.text.length > 0 ? wrappedRows(entry.text, usable) : 1;
127
186
  return separation + thinking + body + (entry.aborted ? 1 : 0);
128
187
  }
129
188
  case 'tool': {
130
- if (!entry.preview || (entry.status !== 'done' && entry.status !== 'error')) {
189
+ const settled = entry.status === 'done' || entry.status === 'error';
190
+ // WHEN A PATCH IS PRESENT IT, NOT THE PREVIEW, IS WHAT DRAWS (§3.4): the
191
+ // stored preview is one line by then, so estimating from it would report a
192
+ // 3-row card for a 12-row diff — an UNDER-estimate, the direction this
193
+ // file names as unsafe.
194
+ if (settled && entry.patch) {
195
+ const rows = entry.patch.lineCount + Math.max(0, entry.patch.hunks.length - 1);
196
+ const shown = expanded ? rows : Math.min(rows, DIFF_COLLAPSED_LINES);
197
+ // card header + `+N -M` summary + rows + footer
198
+ return separation + 1 + 1 + shown + 1;
199
+ }
200
+ // THE ONE-LINE `if (!entry.preview || !settled)` THIS REPLACES COVERED TWO
201
+ // CASES, and both must survive the split (P1-4): not settled, and settled
202
+ // with nothing to draw (an aborted call, a tool that returned an empty
203
+ // string). A `!settled` block alone would drop the second into
204
+ // `wrappedRows(entry.preview, usable)` with `undefined`.
205
+ if (!settled) {
206
+ const live = entry.live?.length ?? 0;
207
+ if (live === 0)
208
+ return separation + 1;
209
+ // Header + tail rows + the unconditional stall/`(running)` footer row.
210
+ //
211
+ // ROWS ARE COUNTED, NOT WRAPPED, which is legitimate only because every
212
+ // live row is rendered `wrap="truncate"` AND clipped at
213
+ // `LIVE_ROW_MAX_CHARS` on the way into the store -- the same argument
214
+ // `DiffView.tsx:6-11` makes for itself, and the same coupling: if a
215
+ // future change lets a live row wrap, this silently UNDER-counts, the
216
+ // direction this file names as the one the layout cannot absorb.
217
+ return separation + 1 + Math.min(live, TOOL_LIVE_TAIL_ROWS) + 1;
218
+ }
219
+ if (!entry.preview) {
131
220
  return separation + 1;
132
221
  }
133
222
  const lines = wrappedRows(entry.preview, usable);
@@ -139,6 +228,21 @@ export function estimateEntryRows(entry, cols, density, expanded) {
139
228
  return separation + 1 + entry.runs.length + (expanded ? entry.runs.length * 3 + 1 : 1);
140
229
  case 'todo':
141
230
  return separation + 1 + entry.items.length;
231
+ // ONE ROW, ALWAYS. The card is `wrap="truncate"` in every phase, so this is an
232
+ // exact height rather than an upper bound.
233
+ case 'retry':
234
+ return separation + 1;
235
+ // ADDING THE CASE TO ONLY ONE OF THIS FILE'S TWO SWITCHES YIELDS A
236
+ // TRANSCRIPT WHOSE SCROLL ARITHMETIC DISAGREES WITH WHAT IT DREW, which
237
+ // manifests as drift rather than as an error (RV-13). Header row, then
238
+ // either the wrapped critique (clamped to `cardTextRows`) or one status
239
+ // line — an upper bound, which is what this function owes its caller.
240
+ case 'fast': {
241
+ const body = entry.status === 'advice' && entry.text
242
+ ? Math.min(FAST_LIMITS.cardTextRows, wrappedRows(entry.text, usable))
243
+ : 1;
244
+ return separation + 1 + body;
245
+ }
142
246
  default:
143
247
  return separation + 1;
144
248
  }
@@ -10,10 +10,10 @@ function keyRows(times) {
10
10
  ['Shift+Tab', 'Toggle plan mode (same as /plan)'],
11
11
  [`Ctrl+C ${times}2`, 'Exit'],
12
12
  ['Ctrl+L', 'Redraw the frame'],
13
- ['Ctrl+T', 'Toggle thinking blocks'],
13
+ ['Ctrl+T', 'Show/hide thinking (off by default)'],
14
14
  ['PgUp / PgDn', 'Scroll a page (transcript, or this overlay)'],
15
15
  ['Shift+Up / Shift+Down', 'Scroll the transcript a line (full-screen mode)'],
16
- ['Wheel', 'Scroll the transcript (over the input box: prompt history)'],
16
+ ['Wheel', 'Scroll the transcript (or the open overlay)'],
17
17
  ['Shift+Wheel', 'Scroll the transcript a page'],
18
18
  ['Up / Down', 'Prompt history (empty input)'],
19
19
  ];
@@ -27,6 +27,7 @@ const COMMANDS = [
27
27
  ['/thinking <level>', 'Set thinking level'],
28
28
  ['/max-tokens [n|auto]', 'Output token cap; no argument reports the effective one'],
29
29
  ['/team [on|off|max n]', 'Team subagents: status, switch, fan-out width'],
30
+ ['/fast [on|off|model|review]', 'Fast model tier: status, switch, model, review cadence'],
30
31
  [
31
32
  '/todo [on|off|panel|follow|clear|continue]',
32
33
  'Todo planning: status, switches, the rail, follow-through',
@@ -67,6 +68,16 @@ const TEAM_ROWS = [
67
68
  ['--team / --no-team', 'Start a session with team mode on or off (also ARAGON_TEAM=0)'],
68
69
  ['team_send / team_wait', 'How subagents message each other; 6 each, one every 15s'],
69
70
  ];
71
+ const FAST_ROWS = [
72
+ ['/fast', 'Status: on/off, the model, the cadence, and this session\'s totals'],
73
+ ['/fast on | off', 'Switch the tier for this session (and save it)'],
74
+ ['/fast model <id>', 'Set the fast model; accepts provider:model'],
75
+ ['/fast same', 'Run the fast tier on the main model'],
76
+ ['/fast review <n> | off', 'Turns between automatic reviews, or turn them off'],
77
+ ['/fast delegate on | off', 'Allow model:"fast" on task subagents'],
78
+ ['--fast / --no-fast', 'Start a session with the tier on or off (also ARAGON_FAST=1)'],
79
+ ['<fast_review>', 'An automated second opinion from the fast model - advice, not the user'],
80
+ ];
70
81
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
71
82
  export function helpRows(theme, caps) {
72
83
  const glyphs = pickGlyphs(caps);
@@ -85,6 +96,9 @@ export function helpRows(theme, caps) {
85
96
  rows.push(section('Team subagents'));
86
97
  for (const [c, d] of TEAM_ROWS)
87
98
  rows.push(pair(c, d));
99
+ rows.push(section('Fast model tier'));
100
+ for (const [c, d] of FAST_ROWS)
101
+ rows.push(pair(c, d));
88
102
  rows.push(section('Skills'));
89
103
  for (const [c, d] of SKILL_ROWS)
90
104
  rows.push(pair(c, d));