@aragon-agent/cli 0.5.4 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +747 -577
  2. package/README.md +1163 -995
  3. package/dist/agent/controller.d.ts +71 -1
  4. package/dist/agent/controller.js +135 -1
  5. package/dist/agent/entry-limits.d.ts +65 -0
  6. package/dist/agent/entry-limits.js +133 -0
  7. package/dist/agent/headless.d.ts +29 -0
  8. package/dist/agent/headless.js +86 -1
  9. package/dist/agent/reducer.d.ts +53 -0
  10. package/dist/agent/reducer.js +145 -14
  11. package/dist/agent/system-prompt.d.ts +8 -0
  12. package/dist/agent/system-prompt.js +2 -0
  13. package/dist/cli.js +76 -3
  14. package/dist/commands/builtins.js +171 -0
  15. package/dist/commands/perf.d.ts +45 -0
  16. package/dist/commands/perf.js +78 -0
  17. package/dist/commands/registry.d.ts +27 -1
  18. package/dist/config/env.d.ts +3 -1
  19. package/dist/config/env.js +74 -1
  20. package/dist/config/load.d.ts +41 -0
  21. package/dist/config/load.js +67 -2
  22. package/dist/config/schema.d.ts +108 -8
  23. package/dist/config/schema.js +81 -6
  24. package/dist/config/store.js +10 -1
  25. package/dist/session/persist.d.ts +19 -1
  26. package/dist/session/persist.js +21 -2
  27. package/dist/team/subagent.js +11 -0
  28. package/dist/todo/follow-through.d.ts +117 -0
  29. package/dist/todo/follow-through.js +186 -0
  30. package/dist/todo/limits.d.ts +114 -0
  31. package/dist/todo/limits.js +114 -0
  32. package/dist/todo/normalize.d.ts +33 -0
  33. package/dist/todo/normalize.js +117 -0
  34. package/dist/todo/panel-rows.d.ts +53 -0
  35. package/dist/todo/panel-rows.js +103 -0
  36. package/dist/todo/prompt.d.ts +54 -0
  37. package/dist/todo/prompt.js +76 -0
  38. package/dist/todo/store.d.ts +86 -0
  39. package/dist/todo/store.js +183 -0
  40. package/dist/todo/todo-tool.d.ts +38 -0
  41. package/dist/todo/todo-tool.js +134 -0
  42. package/dist/todo/types.d.ts +62 -0
  43. package/dist/todo/types.js +12 -0
  44. package/dist/tools/index.d.ts +16 -2
  45. package/dist/tools/index.js +13 -0
  46. package/dist/ui/App.js +328 -14
  47. package/dist/ui/Markdown.d.ts +7 -1
  48. package/dist/ui/Markdown.js +15 -48
  49. package/dist/ui/StatusBar.d.ts +31 -0
  50. package/dist/ui/StatusBar.js +7 -2
  51. package/dist/ui/TodoPanel.d.ts +48 -0
  52. package/dist/ui/TodoPanel.js +74 -0
  53. package/dist/ui/TodoStrip.d.ts +36 -0
  54. package/dist/ui/TodoStrip.js +32 -0
  55. package/dist/ui/Transcript.d.ts +56 -4
  56. package/dist/ui/Transcript.js +123 -11
  57. package/dist/ui/entries/AssistantEntry.d.ts +18 -1
  58. package/dist/ui/entries/AssistantEntry.js +33 -3
  59. package/dist/ui/entries/TeamCard.d.ts +9 -1
  60. package/dist/ui/entries/TeamCard.js +22 -1
  61. package/dist/ui/entries/TodoCard.d.ts +39 -0
  62. package/dist/ui/entries/TodoCard.js +57 -0
  63. package/dist/ui/entries/ToolCard.d.ts +13 -1
  64. package/dist/ui/entries/ToolCard.js +39 -5
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  66. package/dist/ui/entries/UserEntry.d.ts +9 -1
  67. package/dist/ui/entries/UserEntry.js +17 -1
  68. package/dist/ui/exit-snapshot.d.ts +7 -0
  69. package/dist/ui/glyphs.d.ts +12 -0
  70. package/dist/ui/glyphs.js +8 -0
  71. package/dist/ui/layout/AppShell.d.ts +34 -1
  72. package/dist/ui/layout/AppShell.js +3 -3
  73. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  74. package/dist/ui/layout/MeasuredEntry.js +30 -0
  75. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  76. package/dist/ui/layout/ScrollViewport.js +10 -4
  77. package/dist/ui/layout/rail.d.ts +56 -0
  78. package/dist/ui/layout/rail.js +68 -0
  79. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  80. package/dist/ui/layout/viewport-geometry.js +27 -0
  81. package/dist/ui/layout/virtual-window.d.ts +100 -0
  82. package/dist/ui/layout/virtual-window.js +204 -0
  83. package/dist/ui/live-clamp.d.ts +42 -0
  84. package/dist/ui/live-clamp.js +74 -0
  85. package/dist/ui/markdown-blocks.d.ts +25 -1
  86. package/dist/ui/markdown-blocks.js +43 -0
  87. package/dist/ui/overlays/HelpOverlay.js +4 -0
  88. package/dist/ui/render-cache.d.ts +81 -0
  89. package/dist/ui/render-cache.js +195 -0
  90. package/dist/ui/render-governor.d.ts +54 -0
  91. package/dist/ui/render-governor.js +78 -0
  92. package/dist/ui/transcript-text.d.ts +11 -0
  93. package/dist/ui/transcript-text.js +5 -0
  94. package/dist/ui/use-height-store.d.ts +31 -0
  95. package/dist/ui/use-height-store.js +76 -0
  96. package/dist/ui/use-render-governor.d.ts +39 -0
  97. package/dist/ui/use-render-governor.js +57 -0
  98. package/dist/ui/use-startup-notices.d.ts +9 -1
  99. package/dist/ui/use-startup-notices.js +35 -1
  100. package/package.json +3 -2
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Rail geometry (todo-plan-execution §3.9) — the ONE place that says how wide
3
+ * and how tall the todo rail is allowed to be.
4
+ *
5
+ * Pure and React-free, mirroring `budget.ts`, so `todo-rail.test.ts` can pin the
6
+ * arithmetic without mounting anything. Deliberately NOT part of `budget.ts`:
7
+ * that module answers "how many ROWS does the viewport get" and stays a function
8
+ * of `rows` alone; the rail spends COLUMNS.
9
+ *
10
+ * NAMED FOR ITS POSITION, NOT ITS CONTENT, like `AppShellProps.rail` (D-18):
11
+ * this is a layout primitive and `viewportRows` is its neighbour, not
12
+ * `TodoPanel`.
13
+ */
14
+ import { TODO_LIMITS } from '../../todo/limits.js';
15
+ /** R-e, literally: the rail asks for a fifth of the terminal. */
16
+ const RAIL_FRACTION = 0.2;
17
+ /** Below this the marker + a two-word title do not fit and the column is noise. */
18
+ export const TODO_RAIL_MIN_COLS = 18;
19
+ /**
20
+ * Above this, 20% is being spent on whitespace: at 36 columns a step title
21
+ * already fits on one line, and the transcript is the surface that benefits from
22
+ * a 200-column terminal. A deliberate, revertible deviation from a literal
23
+ * reading of R-e; it only binds above 180 columns.
24
+ */
25
+ export const TODO_RAIL_MAX_COLS = 36;
26
+ /** Under 80 columns the transcript needs every column; no rail at all. */
27
+ export const TODO_RAIL_MIN_TOTAL_COLS = 80;
28
+ /** Below this rail width the 2-column index prefix is dropped for content. */
29
+ export const TODO_RAIL_INDEX_MIN_COLS = 22;
30
+ /**
31
+ * Two properties are pinned by tests, for the reason `budget.ts` records about
32
+ * the viewport being non-monotonic before anyone measured it:
33
+ *
34
+ * - NON-DECREASING in `cols` across [40, 300]. A user dragging a window wider
35
+ * must never see the rail get narrower.
36
+ * - `cols - todoRailWidth(cols) >= 62` for every `cols >= 80`. Every existing
37
+ * width heuristic inside the viewport (`TERSE_HINT_COLS` 42,
38
+ * `MIN_INDICATOR_COLS` 50, the status bar's 60/72 steps) sits below that
39
+ * floor, so no existing behaviour changes because a rail appeared (R-3).
40
+ */
41
+ export function todoRailWidth(cols) {
42
+ if (!Number.isFinite(cols) || cols < TODO_RAIL_MIN_TOTAL_COLS)
43
+ return 0;
44
+ return Math.min(TODO_RAIL_MAX_COLS, Math.max(TODO_RAIL_MIN_COLS, Math.round(cols * RAIL_FRACTION)));
45
+ }
46
+ /**
47
+ * How many rows the rail may actually draw.
48
+ *
49
+ * IT IS NOT `viewportBudget`, AND THE DIFFERENCE IS NOT COSMETIC (P1-3 / D-23).
50
+ * `viewportRows()` subtracts the STATIC chrome only, and says so; the live team
51
+ * roster is mounted inside `AppShell`'s measured bottom box, where it costs a
52
+ * header row, up to `TEAM_LIMITS.panelMaxRows` child rows, a `+N more` row and a
53
+ * mail row that `viewportBudget` knows nothing about. `ScrollViewport` is immune
54
+ * because it MEASURES itself; the rail does not measure - a second
55
+ * measure -> setState -> measure loop is exactly what §1.2 refuses - so it has
56
+ * to subtract.
57
+ *
58
+ * Ink clips overflow from the BOTTOM, so an over-request does not error: it
59
+ * silently eats the last row, which is the `+N below` marker - the one row whose
60
+ * absence is indistinguishable from "the list is short". That is why this is
61
+ * arithmetic rather than a shrug at `overflow: hidden`.
62
+ */
63
+ export function todoRailRows(viewportBudget, teamActive) {
64
+ if (!Number.isFinite(viewportBudget))
65
+ return 0;
66
+ const reserved = teamActive ? TODO_LIMITS.railReservedRows : 0;
67
+ return Math.max(0, viewportBudget - reserved);
68
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Viewport geometry, published downward (tui-render-performance L3).
3
+ *
4
+ * `ScrollViewport`'s header records (P1-7) why it — and not its parent — owns
5
+ * the metrics and the scroll offset: holding the offset in two places creates a
6
+ * measure -> setState -> re-render -> measure loop whose convergence is provable
7
+ * but whose frame count is not.
8
+ *
9
+ * Virtualisation needs those same three numbers one level DOWN, in
10
+ * `TranscriptList`. So the viewport PUBLISHES rather than lifting: a `useMemo`d
11
+ * context value, no new state, no second owner, no extra layout pass.
12
+ */
13
+ export interface ViewportGeometry {
14
+ /** Visible height of the clipping box, in rows. `0` before the first measure. */
15
+ viewportRows: number;
16
+ /** Rows hidden BELOW the viewport (`layout/scroll.ts` semantics; V-4). */
17
+ offset: number;
18
+ /** Natural (unclipped) height of the content, in rows. */
19
+ contentRows: number;
20
+ }
21
+ /**
22
+ * The default is the UNMEASURED geometry, and consumers must treat it as such:
23
+ * a `TranscriptList` rendered outside a `ScrollViewport` (inline mode, and every
24
+ * test that mounts it bare) sees `viewportRows: 0`, which `selectWindow` reads
25
+ * as "first frame" and answers by pinning the live tail.
26
+ */
27
+ export declare const ViewportGeometryContext: import("react").Context<ViewportGeometry>;
28
+ export declare function useViewportGeometry(): ViewportGeometry;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Viewport geometry, published downward (tui-render-performance L3).
3
+ *
4
+ * `ScrollViewport`'s header records (P1-7) why it — and not its parent — owns
5
+ * the metrics and the scroll offset: holding the offset in two places creates a
6
+ * measure -> setState -> re-render -> measure loop whose convergence is provable
7
+ * but whose frame count is not.
8
+ *
9
+ * Virtualisation needs those same three numbers one level DOWN, in
10
+ * `TranscriptList`. So the viewport PUBLISHES rather than lifting: a `useMemo`d
11
+ * context value, no new state, no second owner, no extra layout pass.
12
+ */
13
+ import { createContext, useContext } from 'react';
14
+ /**
15
+ * The default is the UNMEASURED geometry, and consumers must treat it as such:
16
+ * a `TranscriptList` rendered outside a `ScrollViewport` (inline mode, and every
17
+ * test that mounts it bare) sees `viewportRows: 0`, which `selectWindow` reads
18
+ * as "first frame" and answers by pinning the live tail.
19
+ */
20
+ export const ViewportGeometryContext = createContext({
21
+ viewportRows: 0,
22
+ offset: 0,
23
+ contentRows: 0,
24
+ });
25
+ export function useViewportGeometry() {
26
+ return useContext(ViewportGeometryContext);
27
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Viewport virtualisation maths (tui-render-performance L3 / R1). Pure,
3
+ * React-free, unit-tested.
4
+ *
5
+ * THE STRUCTURAL FIX. Ink's `renderNodeToOutput` has no early-out for clipped
6
+ * subtrees: for every `ink-text` node it runs `squashTextNodes`, `widestLine`
7
+ * and possibly `wrapText` — all O(chars) — and `Output.get()` then evaluates
8
+ * `widestLine` a SECOND time before it is allowed to skip an out-of-clip
9
+ * operation. A clipped 50 000-character entry therefore costs two full string
10
+ * scans per frame and buys nothing. Bounding the ENTRY COUNT (`transcriptWindow`)
11
+ * never bounded that, because one entry can be fifty thousand lines.
12
+ *
13
+ * So entries outside the visible band are not mounted at all. They are replaced
14
+ * by two spacer boxes whose heights come from a height cache, which makes the
15
+ * per-frame cost O(visible rows) instead of O(characters ever produced).
16
+ *
17
+ * V-4 — `offset` counts rows FROM THE BOTTOM (`layout/scroll.ts:1-13`), and that
18
+ * is what makes virtualisation safe here for free: a height correction on an
19
+ * entry ABOVE the viewport changes the content height and therefore
20
+ * `overflowLines`, while `offset` — and hence what the user is looking at — is
21
+ * unchanged. No scroll-anchoring compensation pass is required. Do NOT
22
+ * "simplify" `offset` to count from the top.
23
+ */
24
+ import type { Entry } from '../../agent/reducer.js';
25
+ import type { DensityMode } from '../density.js';
26
+ export declare const VIRTUAL_LIMITS: {
27
+ /**
28
+ * Entries kept mounted on each side of the visible band.
29
+ *
30
+ * V-2 — must stay `>= 2`, so a one-row scroll can never expose an entry whose
31
+ * height has never been measured at the very edge of the viewport.
32
+ */
33
+ readonly overscan: 2;
34
+ /** Height-cache ceiling (LRU). Four thousand numbers, not four thousand strings. */
35
+ readonly heightEntries: 4096;
36
+ };
37
+ export type HeightKey = string;
38
+ /**
39
+ * A cheap content version: it changes exactly when the rendered content does.
40
+ *
41
+ * DERIVED, NEVER HASHED. Hashing a 256 KiB string once per entry per frame would
42
+ * recreate the very cost this module exists to remove, so every field below is
43
+ * an O(1) read of a length or a scalar.
44
+ *
45
+ * I-L3-1 — this must change whenever the rendered output changes. The only lossy
46
+ * case is a same-LENGTH text replacement, which the reducer cannot produce: all
47
+ * text fields are append-only (`appendBounded` only ever elides the middle,
48
+ * which changes the length). A future NON-APPEND mutation MUST extend the
49
+ * revision, or the entry freezes at a stale height AND a stale rendered subtree
50
+ * with nothing anywhere reporting it. `virtual-window.test.ts` enumerates every
51
+ * kind against every mutating action.
52
+ */
53
+ export declare function entryRevision(entry: Entry): string;
54
+ /**
55
+ * The height-cache key.
56
+ *
57
+ * V-5 — `cols` is part of the key, so a resize invalidates every entry. The
58
+ * frame after a resize renders from estimates and re-measures lazily: one
59
+ * reflow, no crash, no stale layout.
60
+ *
61
+ * `flags` carries the render-affecting switches that are neither per-entry nor
62
+ * part of `density` — today only `thinkingVisible` (Ctrl+T). It is a parameter
63
+ * rather than a fifth positional scalar so a future switch costs one character
64
+ * at the call site instead of a signature change at four.
65
+ */
66
+ export declare function heightKey(entry: Entry, cols: number, expanded: boolean, density: DensityMode, flags?: string): HeightKey;
67
+ /**
68
+ * Pure UPPER-BOUND estimate of an entry's height, used until a real measurement
69
+ * exists.
70
+ *
71
+ * Upper bound rather than best guess, and deliberately so: an over-estimate
72
+ * makes the scroll position slightly conservative and self-corrects the frame
73
+ * after the entry is measured, while an under-estimate can put the newest output
74
+ * off the bottom of a viewport the user believes is pinned.
75
+ */
76
+ export declare function estimateEntryRows(entry: Entry, cols: number, density: DensityMode, expanded: boolean): number;
77
+ export interface WindowSelection {
78
+ /** First mounted entry, inclusive. */
79
+ startIndex: number;
80
+ /** One past the last mounted entry. */
81
+ endIndex: number;
82
+ /** Spacer rows above the mounted slice. */
83
+ leadingRows: number;
84
+ /** Spacer rows below the mounted slice. */
85
+ trailingRows: number;
86
+ /** Sum of every entry height, measured or estimated. */
87
+ totalRows: number;
88
+ }
89
+ export interface SelectWindowInput {
90
+ entries: readonly Entry[];
91
+ /** Measured, else estimated; never 0 for a real entry. */
92
+ heightOf: (index: number) => number;
93
+ viewportRows: number;
94
+ /** Rows hidden BELOW the viewport — the existing `scroll.ts` semantic (V-4). */
95
+ offset: number;
96
+ /** `>= 2` (V-2). Lower values are raised rather than honoured. */
97
+ overscan: number;
98
+ }
99
+ /** Choose which entries to mount, and how tall the two spacers must be. */
100
+ export declare function selectWindow(input: SelectWindowInput): WindowSelection;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Viewport virtualisation maths (tui-render-performance L3 / R1). Pure,
3
+ * React-free, unit-tested.
4
+ *
5
+ * THE STRUCTURAL FIX. Ink's `renderNodeToOutput` has no early-out for clipped
6
+ * subtrees: for every `ink-text` node it runs `squashTextNodes`, `widestLine`
7
+ * and possibly `wrapText` — all O(chars) — and `Output.get()` then evaluates
8
+ * `widestLine` a SECOND time before it is allowed to skip an out-of-clip
9
+ * operation. A clipped 50 000-character entry therefore costs two full string
10
+ * scans per frame and buys nothing. Bounding the ENTRY COUNT (`transcriptWindow`)
11
+ * never bounded that, because one entry can be fifty thousand lines.
12
+ *
13
+ * So entries outside the visible band are not mounted at all. They are replaced
14
+ * by two spacer boxes whose heights come from a height cache, which makes the
15
+ * per-frame cost O(visible rows) instead of O(characters ever produced).
16
+ *
17
+ * V-4 — `offset` counts rows FROM THE BOTTOM (`layout/scroll.ts:1-13`), and that
18
+ * is what makes virtualisation safe here for free: a height correction on an
19
+ * entry ABOVE the viewport changes the content height and therefore
20
+ * `overflowLines`, while `offset` — and hence what the user is looking at — is
21
+ * unchanged. No scroll-anchoring compensation pass is required. Do NOT
22
+ * "simplify" `offset` to count from the top.
23
+ */
24
+ import { GUTTER_WIDTH } from './Gutter.js';
25
+ export const VIRTUAL_LIMITS = {
26
+ /**
27
+ * Entries kept mounted on each side of the visible band.
28
+ *
29
+ * V-2 — must stay `>= 2`, so a one-row scroll can never expose an entry whose
30
+ * height has never been measured at the very edge of the viewport.
31
+ */
32
+ overscan: 2,
33
+ /** Height-cache ceiling (LRU). Four thousand numbers, not four thousand strings. */
34
+ heightEntries: 4096,
35
+ };
36
+ /**
37
+ * A cheap content version: it changes exactly when the rendered content does.
38
+ *
39
+ * DERIVED, NEVER HASHED. Hashing a 256 KiB string once per entry per frame would
40
+ * recreate the very cost this module exists to remove, so every field below is
41
+ * an O(1) read of a length or a scalar.
42
+ *
43
+ * I-L3-1 — this must change whenever the rendered output changes. The only lossy
44
+ * case is a same-LENGTH text replacement, which the reducer cannot produce: all
45
+ * text fields are append-only (`appendBounded` only ever elides the middle,
46
+ * which changes the length). A future NON-APPEND mutation MUST extend the
47
+ * revision, or the entry freezes at a stale height AND a stale rendered subtree
48
+ * with nothing anywhere reporting it. `virtual-window.test.ts` enumerates every
49
+ * kind against every mutating action.
50
+ */
51
+ export function entryRevision(entry) {
52
+ switch (entry.kind) {
53
+ case 'user':
54
+ return 'u';
55
+ case 'notice':
56
+ return 'n';
57
+ case 'assistant':
58
+ return `a${entry.text.length}.${entry.thinking?.length ?? 0}.${entry.streaming ? 1 : 0}.${entry.aborted ? 1 : 0}`;
59
+ case 'tool':
60
+ return `t${entry.status}.${entry.argsRaw.length}.${entry.preview?.length ?? 0}.${entry.durationMs ?? -1}`;
61
+ case 'team':
62
+ return `m${entry.active ? 1 : 0}.${entry.runs.length}.${entry.runs
63
+ .map((r) => r.phase[0])
64
+ .join('')}.${entry.runs.reduce((acc, r) => acc + r.toolCalls, 0)}`;
65
+ case 'todo':
66
+ return `d${entry.live ? 1 : 0}.${entry.doneCount}.${entry.total}.${entry.items.length}`;
67
+ default:
68
+ return 'x';
69
+ }
70
+ }
71
+ /**
72
+ * The height-cache key.
73
+ *
74
+ * V-5 — `cols` is part of the key, so a resize invalidates every entry. The
75
+ * frame after a resize renders from estimates and re-measures lazily: one
76
+ * reflow, no crash, no stale layout.
77
+ *
78
+ * `flags` carries the render-affecting switches that are neither per-entry nor
79
+ * part of `density` — today only `thinkingVisible` (Ctrl+T). It is a parameter
80
+ * rather than a fifth positional scalar so a future switch costs one character
81
+ * at the call site instead of a signature change at four.
82
+ */
83
+ export function heightKey(entry, cols, expanded, density, flags = '') {
84
+ return `${entry.id}|${entryRevision(entry)}|${cols}|${expanded ? 1 : 0}|${density}|${flags}`;
85
+ }
86
+ /** Rows a string occupies at `usable` columns, wrapping included. At least 1. */
87
+ function wrappedRows(text, usable) {
88
+ const width = Math.max(1, usable);
89
+ let rows = 0;
90
+ let lineLen = 0;
91
+ for (let i = 0; i < text.length; i += 1) {
92
+ if (text.charCodeAt(i) === 10) {
93
+ rows += Math.max(1, Math.ceil(lineLen / width));
94
+ lineLen = 0;
95
+ }
96
+ else {
97
+ lineLen += 1;
98
+ }
99
+ }
100
+ return rows + Math.max(1, Math.ceil(lineLen / width));
101
+ }
102
+ /** Collapsed preview rows in `ToolCard`; duplicated as a NUMBER, not imported. */
103
+ const TOOL_COLLAPSED_LINES = 8;
104
+ /**
105
+ * Pure UPPER-BOUND estimate of an entry's height, used until a real measurement
106
+ * exists.
107
+ *
108
+ * Upper bound rather than best guess, and deliberately so: an over-estimate
109
+ * makes the scroll position slightly conservative and self-corrects the frame
110
+ * after the entry is measured, while an under-estimate can put the newest output
111
+ * off the bottom of a viewport the user believes is pinned.
112
+ */
113
+ export function estimateEntryRows(entry, cols, density, expanded) {
114
+ const usable = Math.max(1, cols - GUTTER_WIDTH);
115
+ // `separationRows` needs the PRECEDING entry, which a per-entry estimate does
116
+ // not have. One row is its maximum, so charging it unconditionally keeps this
117
+ // an upper bound (`compact` never spends it at all).
118
+ const separation = density === 'comfortable' ? 1 : 0;
119
+ switch (entry.kind) {
120
+ case 'user':
121
+ return separation + wrappedRows(entry.text, usable);
122
+ case 'notice':
123
+ return separation + wrappedRows(entry.text, usable);
124
+ case 'assistant': {
125
+ const thinking = entry.thinking ? 1 + wrappedRows(entry.thinking, usable) : 0;
126
+ const body = entry.text.length > 0 ? wrappedRows(entry.text, usable) : 1;
127
+ return separation + thinking + body + (entry.aborted ? 1 : 0);
128
+ }
129
+ case 'tool': {
130
+ if (!entry.preview || (entry.status !== 'done' && entry.status !== 'error')) {
131
+ return separation + 1;
132
+ }
133
+ const lines = wrappedRows(entry.preview, usable);
134
+ const shown = expanded ? lines : Math.min(lines, TOOL_COLLAPSED_LINES);
135
+ // header + body + the `+N lines (Ctrl+O)` / `(Ctrl+O to collapse)` footer
136
+ return separation + 1 + shown + 1;
137
+ }
138
+ case 'team':
139
+ return separation + 1 + entry.runs.length + (expanded ? entry.runs.length * 3 + 1 : 1);
140
+ case 'todo':
141
+ return separation + 1 + entry.items.length;
142
+ default:
143
+ return separation + 1;
144
+ }
145
+ }
146
+ /** Choose which entries to mount, and how tall the two spacers must be. */
147
+ export function selectWindow(input) {
148
+ const { entries, heightOf } = input;
149
+ const n = entries.length;
150
+ if (n === 0) {
151
+ return { startIndex: 0, endIndex: 0, leadingRows: 0, trailingRows: 0, totalRows: 0 };
152
+ }
153
+ const heights = new Array(n);
154
+ let totalRows = 0;
155
+ for (let i = 0; i < n; i += 1) {
156
+ const h = heightOf(i);
157
+ const rows = Number.isFinite(h) && h > 0 ? Math.floor(h) : 1;
158
+ heights[i] = rows;
159
+ totalRows += rows;
160
+ }
161
+ // A viewport of 0 is the FIRST FRAME, before `measureElement` has run. Falling
162
+ // back to 1 keeps the live tail mounted through it, so the first paint is real
163
+ // content rather than a blank rectangle that only fills in on the second.
164
+ const viewport = Math.max(1, Math.floor(input.viewportRows) || 0);
165
+ const offset = Math.max(0, Math.floor(input.offset) || 0);
166
+ const overscan = Math.max(VIRTUAL_LIMITS.overscan, Math.floor(input.overscan) || 0);
167
+ const bottom = totalRows - offset;
168
+ const top = bottom - viewport;
169
+ let start = n - 1;
170
+ let acc = 0;
171
+ for (let i = 0; i < n; i += 1) {
172
+ const next = acc + heights[i];
173
+ if (next > top) {
174
+ start = i;
175
+ break;
176
+ }
177
+ acc = next;
178
+ }
179
+ let end = n;
180
+ acc = 0;
181
+ for (let i = 0; i < n; i += 1) {
182
+ if (acc >= bottom) {
183
+ end = i;
184
+ break;
185
+ }
186
+ acc += heights[i];
187
+ }
188
+ start = Math.max(0, start - overscan);
189
+ end = Math.min(n, end + overscan);
190
+ // V-3 — while pinned, the LAST entry is always inside the window, whatever its
191
+ // height. The live tail must be real content and never a spacer, or a
192
+ // streaming answer renders as a growing blank rectangle.
193
+ if (offset <= 0)
194
+ end = n;
195
+ if (end <= start)
196
+ end = Math.min(n, start + 1);
197
+ let leadingRows = 0;
198
+ for (let i = 0; i < start; i += 1)
199
+ leadingRows += heights[i];
200
+ let trailingRows = 0;
201
+ for (let i = end; i < n; i += 1)
202
+ trailingRows += heights[i];
203
+ return { startIndex: start, endIndex: end, leadingRows, trailingRows, totalRows };
204
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Live-region clamp for INLINE render mode (tui-render-performance L5 / R6).
3
+ *
4
+ * Inline mode has no fixed frame, so nothing kept its live (non-`<Static>`)
5
+ * region shorter than the terminal. When one live entry grew taller than
6
+ * `stdout.rows`, Ink took the branch at `ink.js:118-123`:
7
+ *
8
+ * if (outputHeight >= this.options.stdout.rows) {
9
+ * stdout.write(clearTerminal + this.fullStaticOutput + output);
10
+ * }
11
+ *
12
+ * `fullStaticOutput` accumulates EVERY `<Static>` frame of the whole session and
13
+ * is never trimmed, so once that branch is taken it is taken on every subsequent
14
+ * frame — bypassing both the `output !== lastOutput` dedupe and `throttledLog` —
15
+ * and the terminal receives the entire session history thirty times a second.
16
+ * That is a genuine hard freeze, and it is reachable today by any inline user
17
+ * (`--no-fullscreen`, `TERM=dumb`, CI, a short or narrow terminal) whose model
18
+ * writes a long answer.
19
+ *
20
+ * I-L5-1 — keeping the live region strictly below `stdout.rows` makes that
21
+ * branch UNREACHABLE, which is the inline analogue of `frame.ts`'s
22
+ * `frameHeight(r) < r` and deserves the same standing. We cannot bound Ink's
23
+ * `fullStaticOutput` growth (it is private and append-only); preventing the
24
+ * branch that WRITES it is the entire mitigation, and nothing about the memory
25
+ * it holds is fixed by this module.
26
+ *
27
+ * Nothing is lost, only deferred: when the entry settles it moves into
28
+ * `<Static>` and is printed in full.
29
+ */
30
+ export interface LiveClamp {
31
+ text: string;
32
+ /** Rows dropped off the FRONT. `0` means the text was returned untouched. */
33
+ hiddenRows: number;
34
+ }
35
+ /**
36
+ * Keep the LAST `maxRows` rows of `text`; report how many were dropped.
37
+ *
38
+ * Rows, not wrapped display lines: this runs per frame on a streaming entry, and
39
+ * a wrap-aware count would need the column width and a second full scan of a
40
+ * string this function exists to avoid scanning twice.
41
+ */
42
+ export declare function clampLiveText(text: string, maxRows: number): LiveClamp;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Live-region clamp for INLINE render mode (tui-render-performance L5 / R6).
3
+ *
4
+ * Inline mode has no fixed frame, so nothing kept its live (non-`<Static>`)
5
+ * region shorter than the terminal. When one live entry grew taller than
6
+ * `stdout.rows`, Ink took the branch at `ink.js:118-123`:
7
+ *
8
+ * if (outputHeight >= this.options.stdout.rows) {
9
+ * stdout.write(clearTerminal + this.fullStaticOutput + output);
10
+ * }
11
+ *
12
+ * `fullStaticOutput` accumulates EVERY `<Static>` frame of the whole session and
13
+ * is never trimmed, so once that branch is taken it is taken on every subsequent
14
+ * frame — bypassing both the `output !== lastOutput` dedupe and `throttledLog` —
15
+ * and the terminal receives the entire session history thirty times a second.
16
+ * That is a genuine hard freeze, and it is reachable today by any inline user
17
+ * (`--no-fullscreen`, `TERM=dumb`, CI, a short or narrow terminal) whose model
18
+ * writes a long answer.
19
+ *
20
+ * I-L5-1 — keeping the live region strictly below `stdout.rows` makes that
21
+ * branch UNREACHABLE, which is the inline analogue of `frame.ts`'s
22
+ * `frameHeight(r) < r` and deserves the same standing. We cannot bound Ink's
23
+ * `fullStaticOutput` growth (it is private and append-only); preventing the
24
+ * branch that WRITES it is the entire mitigation, and nothing about the memory
25
+ * it holds is fixed by this module.
26
+ *
27
+ * Nothing is lost, only deferred: when the entry settles it moves into
28
+ * `<Static>` and is printed in full.
29
+ */
30
+ /**
31
+ * Keep the LAST `maxRows` rows of `text`; report how many were dropped.
32
+ *
33
+ * Rows, not wrapped display lines: this runs per frame on a streaming entry, and
34
+ * a wrap-aware count would need the column width and a second full scan of a
35
+ * string this function exists to avoid scanning twice.
36
+ */
37
+ export function clampLiveText(text, maxRows) {
38
+ if (!Number.isFinite(maxRows) || maxRows <= 0)
39
+ return { text: '', hiddenRows: rowCount(text) };
40
+ const keep = Math.floor(maxRows);
41
+ // Cheap reject: a string with fewer than `keep` newlines cannot have more than
42
+ // `keep` rows, and this is the path every ordinary entry takes.
43
+ let newlines = 0;
44
+ for (let i = 0; i < text.length; i += 1) {
45
+ if (text.charCodeAt(i) === 10)
46
+ newlines += 1;
47
+ }
48
+ if (newlines + 1 <= keep)
49
+ return { text, hiddenRows: 0 };
50
+ // Walk back from the end to the `keep`-th newline rather than splitting the
51
+ // whole string: the point of the clamp is to stop touching the head at all.
52
+ let cut = text.length;
53
+ let seen = 0;
54
+ for (let i = text.length - 1; i >= 0; i -= 1) {
55
+ if (text.charCodeAt(i) === 10) {
56
+ seen += 1;
57
+ if (seen === keep) {
58
+ cut = i + 1;
59
+ break;
60
+ }
61
+ }
62
+ }
63
+ return { text: text.slice(cut), hiddenRows: newlines + 1 - keep };
64
+ }
65
+ function rowCount(text) {
66
+ if (text.length === 0)
67
+ return 0;
68
+ let n = 1;
69
+ for (let i = 0; i < text.length; i += 1) {
70
+ if (text.charCodeAt(i) === 10)
71
+ n += 1;
72
+ }
73
+ return n;
74
+ }
@@ -15,11 +15,13 @@ export interface HeadingBlock {
15
15
  export interface RuleBlock {
16
16
  kind: 'rule';
17
17
  }
18
+ /** Column alignment carried by a table's `|---|:--:|` divider row. */
19
+ export type Align = 'left' | 'right' | 'center';
18
20
  export interface TableBlock {
19
21
  kind: 'table';
20
22
  /** Already column-aligned; render as plain monospace rows. */
21
23
  rows: string[][];
22
- align: ('left' | 'right' | 'center')[];
24
+ align: Align[];
23
25
  }
24
26
  export declare function parseHeading(line: string): HeadingBlock | null;
25
27
  export declare function isRule(line: string): boolean;
@@ -34,6 +36,28 @@ export declare function tableAlignments(dividerLine: string): TableBlock['align'
34
36
  * on any realistic terminal.
35
37
  */
36
38
  export declare function formatTable(rows: string[][], align: TableBlock['align']): string[];
39
+ /**
40
+ * One parsed top-level block.
41
+ *
42
+ * `line` is deliberately UNPARSED: heading / rule / quote / bullet detection is
43
+ * a handful of regexes over one short string and is done at render time, where
44
+ * the theme and the glyph set live. Caching a themed element instead would make
45
+ * `/theme` a no-op until the cache evicted (K-5).
46
+ */
47
+ export type MdBlock = {
48
+ kind: 'code';
49
+ code: string;
50
+ lang: string;
51
+ } | {
52
+ kind: 'table';
53
+ rows: string[][];
54
+ align: Align[];
55
+ } | {
56
+ kind: 'line';
57
+ text: string;
58
+ };
59
+ /** Split a document into fenced code, pipe tables, and everything else. */
60
+ export declare function parseMarkdownBlocks(text: string): MdBlock[];
37
61
  export type InlineSpan = {
38
62
  kind: 'text';
39
63
  text: string;
@@ -73,6 +73,49 @@ export function formatTable(rows, align) {
73
73
  .join(' ')
74
74
  .trimEnd());
75
75
  }
76
+ /** A fence line: ```` ```lang ```` or a bare ```` ``` ````. */
77
+ const FENCE_RE = /^```(\w*)\s*$/;
78
+ /** Split a document into fenced code, pipe tables, and everything else. */
79
+ export function parseMarkdownBlocks(text) {
80
+ const lines = text.split('\n');
81
+ const blocks = [];
82
+ let i = 0;
83
+ while (i < lines.length) {
84
+ const line = lines[i] ?? '';
85
+ const fence = FENCE_RE.exec(line.trim());
86
+ if (fence) {
87
+ const lang = fence[1] || '';
88
+ const codeLines = [];
89
+ i += 1;
90
+ while (i < lines.length && !FENCE_RE.test((lines[i] ?? '').trim())) {
91
+ codeLines.push(lines[i] ?? '');
92
+ i += 1;
93
+ }
94
+ i += 1; // skip the closing fence
95
+ blocks.push({ kind: 'code', code: codeLines.join('\n'), lang });
96
+ continue;
97
+ }
98
+ // A pipe row followed by a divider row starts a table; consume the run.
99
+ const headerCells = parseTableRow(line);
100
+ if (headerCells && i + 1 < lines.length && isTableDivider(lines[i + 1] ?? '')) {
101
+ const align = tableAlignments(lines[i + 1] ?? '');
102
+ const rows = [headerCells];
103
+ i += 2;
104
+ while (i < lines.length) {
105
+ const cells = parseTableRow(lines[i] ?? '');
106
+ if (!cells)
107
+ break;
108
+ rows.push(cells);
109
+ i += 1;
110
+ }
111
+ blocks.push({ kind: 'table', rows, align });
112
+ continue;
113
+ }
114
+ blocks.push({ kind: 'line', text: line });
115
+ i += 1;
116
+ }
117
+ return blocks;
118
+ }
76
119
  const CODE_RE = /`[^`]+`/g;
77
120
  const EMPHASIS_RE = /(\*\*[^*]+\*\*|~~[^~]+~~|\*[^*]+\*)/g;
78
121
  /**
@@ -27,6 +27,10 @@ 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
+ [
31
+ '/todo [on|off|panel|follow|clear|continue]',
32
+ 'Todo planning: status, switches, the rail, follow-through',
33
+ ],
30
34
  ['/tools', 'List active tools'],
31
35
  ['/clear', 'Clear the visible transcript'],
32
36
  ['/reset', 'New conversation'],