@aragon-agent/cli 0.6.8 → 0.6.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 (97) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +89 -44
  3. package/dist/agent/controller.d.ts +1 -1
  4. package/dist/agent/reducer.d.ts +31 -113
  5. package/dist/agent/reducer.js +41 -63
  6. package/dist/cli.js +53 -155
  7. package/dist/commands/builtins.d.ts +2 -1
  8. package/dist/commands/builtins.js +47 -2
  9. package/dist/commands/perf.d.ts +32 -2
  10. package/dist/commands/perf.js +32 -2
  11. package/dist/commands/registry.d.ts +2 -2
  12. package/dist/config/env.js +0 -130
  13. package/dist/config/load.d.ts +0 -2
  14. package/dist/config/load.js +2 -31
  15. package/dist/config/schema.d.ts +1 -14
  16. package/dist/config/schema.js +0 -1
  17. package/dist/config/store.js +2 -0
  18. package/dist/input/enter-sequences.d.ts +63 -0
  19. package/dist/input/enter-sequences.js +151 -0
  20. package/dist/input/limits.d.ts +11 -0
  21. package/dist/input/limits.js +11 -0
  22. package/dist/input/stdin-filter.js +19 -1
  23. package/dist/logging/install.d.ts +2 -2
  24. package/dist/logging/install.js +2 -2
  25. package/dist/session/persist.js +20 -0
  26. package/dist/todo/follow-through.d.ts +0 -4
  27. package/dist/todo/follow-through.js +0 -4
  28. package/dist/todo/limits.d.ts +0 -10
  29. package/dist/todo/limits.js +0 -10
  30. package/dist/todo/prompt.d.ts +0 -15
  31. package/dist/ui/ActivityLine.d.ts +26 -34
  32. package/dist/ui/ActivityLine.js +30 -19
  33. package/dist/ui/App.d.ts +1 -41
  34. package/dist/ui/App.js +94 -129
  35. package/dist/ui/BottomStatusRow.d.ts +1 -3
  36. package/dist/ui/BottomStatusRow.js +4 -8
  37. package/dist/ui/Composer.d.ts +28 -1
  38. package/dist/ui/Composer.js +46 -17
  39. package/dist/ui/Header.d.ts +2 -16
  40. package/dist/ui/Header.js +3 -18
  41. package/dist/ui/Logo.d.ts +1 -17
  42. package/dist/ui/Logo.js +0 -16
  43. package/dist/ui/ModeChip.d.ts +7 -18
  44. package/dist/ui/ModeChip.js +9 -0
  45. package/dist/ui/PromptInput.d.ts +2 -28
  46. package/dist/ui/PromptInput.js +38 -9
  47. package/dist/ui/StatusBar.d.ts +0 -11
  48. package/dist/ui/ToastStack.d.ts +2 -18
  49. package/dist/ui/ToastStack.js +4 -11
  50. package/dist/ui/Transcript.d.ts +5 -71
  51. package/dist/ui/Transcript.js +15 -199
  52. package/dist/ui/enter-frames.d.ts +63 -0
  53. package/dist/ui/enter-frames.js +136 -0
  54. package/dist/ui/entries/AssistantEntry.d.ts +0 -23
  55. package/dist/ui/entries/AssistantEntry.js +3 -4
  56. package/dist/ui/entries/DiffView.d.ts +1 -7
  57. package/dist/ui/entries/DiffView.js +2 -2
  58. package/dist/ui/entries/QueuedEntry.d.ts +27 -0
  59. package/dist/ui/entries/QueuedEntry.js +30 -0
  60. package/dist/ui/entries/ToolCard.d.ts +0 -6
  61. package/dist/ui/entries/ToolCard.js +4 -13
  62. package/dist/ui/frame-differ.d.ts +13 -0
  63. package/dist/ui/frame-differ.js +36 -6
  64. package/dist/ui/glyphs.d.ts +9 -0
  65. package/dist/ui/glyphs.js +2 -0
  66. package/dist/ui/ink-runtime.d.ts +1 -0
  67. package/dist/ui/ink-runtime.js +21 -0
  68. package/dist/ui/layout/AppShell.d.ts +1 -10
  69. package/dist/ui/layout/AppShell.js +2 -5
  70. package/dist/ui/layout/OverlayFrame.d.ts +1 -25
  71. package/dist/ui/layout/OverlayFrame.js +0 -24
  72. package/dist/ui/layout/ScrollViewport.d.ts +3 -0
  73. package/dist/ui/layout/ScrollViewport.js +17 -5
  74. package/dist/ui/layout/frame.d.ts +0 -22
  75. package/dist/ui/layout/frame.js +0 -36
  76. package/dist/ui/layout/todo-layout.d.ts +1 -3
  77. package/dist/ui/layout/todo-layout.js +7 -8
  78. package/dist/ui/layout/viewport-geometry.d.ts +1 -1
  79. package/dist/ui/layout/viewport-geometry.js +1 -1
  80. package/dist/ui/live-clamp.d.ts +1 -29
  81. package/dist/ui/live-clamp.js +1 -29
  82. package/dist/ui/overlays/HelpOverlay.js +3 -1
  83. package/dist/ui/overlays/PlanReviewOverlay.js +5 -1
  84. package/dist/ui/overlays/QuestionOverlay.js +7 -2
  85. package/dist/ui/overlays/SettingsScreen.js +8 -1
  86. package/dist/ui/run-status-row.d.ts +61 -0
  87. package/dist/ui/run-status-row.js +70 -0
  88. package/dist/ui/selection/selection-controller.d.ts +23 -7
  89. package/dist/ui/selection/selection-controller.js +68 -16
  90. package/dist/ui/transcript-text.d.ts +0 -8
  91. package/dist/ui/transcript-text.js +0 -8
  92. package/dist/ui/use-startup-notices.d.ts +6 -58
  93. package/dist/ui/use-startup-notices.js +10 -57
  94. package/dist/ui/use-wheel-routing.d.ts +1 -1
  95. package/package.json +2 -2
  96. package/dist/ui/TodoStrip.d.ts +0 -36
  97. package/dist/ui/TodoStrip.js +0 -32
@@ -1,38 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /**
3
- * Transcript (spec §3.8). Splits the entry list into a settled prefix rendered
4
- * once into Ink's `<Static>` (native terminal scrollback, never re-rendered)
5
- * and a live tail rendered normally. This kills per-token full re-renders and
6
- * flicker on long/fast sessions.
7
- *
8
- * `computeSettledCount` is pure and exported for `transcript-static.test.ts`.
9
- * The settled boundary is held MONOTONIC at render time: a previously-settled
10
- * entry never leaves `<Static>` (which cannot un-print it) even if it later
11
- * becomes expanded, so the live tail can never duplicate a scrolled-off entry.
12
- *
13
- * `EntryView` is shared by both render paths ON PURPOSE (§4.3 / §12). Forking it
14
- * to freeze inline's v0.2.0 output would mean writing every future entry-layer
15
- * change twice and watching the two drift; inline is a degradation path, not a
16
- * parallel product. What inline still guarantees is its GEOMETRY contract — no
17
- * fixed frame, `<Static>` keeps the settled history, no private ANSI — not its
18
- * pixel-for-pixel appearance.
19
- *
20
- * Two things changed with tui-render-performance:
21
- * - `EntryView` is `React.memo`'d with an explicit comparator (L2 / R3), which
22
- * is only worth anything because `mapEntry` already preserves object
23
- * identity for untouched entries;
24
- * - `TranscriptList` (the FULL-SCREEN body) mounts only the entries inside the
25
- * viewport and replaces the rest with two spacer boxes (L3 / R1), and the
26
- * inline `Transcript` clamps its live region so `ink.js:121` is unreachable
27
- * (L5 / R6).
28
- */
29
- import React, { useCallback, useRef } from 'react';
30
- import { Box, Static, Text } from 'ink';
2
+ /** Virtualized full-screen transcript and entry cards. */
3
+ /** Virtualized full-screen transcript and shared entry cards. */
4
+ import React, { useCallback } from 'react';
5
+ import { Box, Text } from 'ink';
31
6
  import Spinner from 'ink-spinner';
32
7
  import { pickGlyphs, toolGlyph } from './glyphs.js';
33
8
  import { separationRows } from './density.js';
34
9
  import { EntryFrame } from './entries/EntryFrame.js';
35
10
  import { UserEntry } from './entries/UserEntry.js';
11
+ import { QueuedEntry } from './entries/QueuedEntry.js';
36
12
  import { AssistantEntry } from './entries/AssistantEntry.js';
37
13
  import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
38
14
  import { TeamCard, teamCardColor } from './entries/TeamCard.js';
@@ -45,98 +21,6 @@ import { MeasuredEntry } from './layout/MeasuredEntry.js';
45
21
  import { useViewportGeometry } from './layout/viewport-geometry.js';
46
22
  import { estimateEntryRows, heightKey, selectWindow, VIRTUAL_LIMITS, } from './layout/virtual-window.js';
47
23
  import { advanceTailRows } from './layout/follow-state.js';
48
- import { useTerminalSize } from './layout/useTerminalSize.js';
49
- /** Keep the last K entries in the live region so a mutating tail stays hot. */
50
- const LIVE_TAIL = 1;
51
- /**
52
- * Rows the inline live region leaves for the composer, the prompt echo and the
53
- * one-row margin Ink needs before `outputHeight >= stdout.rows` (I-L5-1).
54
- */
55
- const INLINE_LIVE_MARGIN = 4;
56
- /** Floor on the clamp, so a very short terminal still shows something useful. */
57
- const INLINE_LIVE_MIN_ROWS = 4;
58
- /**
59
- * Largest prefix length P such that `entries[0..P)` are all terminal, none is
60
- * within the last `LIVE_TAIL` entries, and none is currently expanded. Pure and
61
- * non-decreasing as entries terminalize (expanding a card can lower it — the
62
- * component clamps with a monotonic high-water mark to avoid Static duplication).
63
- */
64
- export function computeSettledCount(entries, expandedToolIds) {
65
- const limit = entries.length - LIVE_TAIL;
66
- let count = 0;
67
- for (let i = 0; i < entries.length; i += 1) {
68
- if (i >= limit)
69
- break;
70
- const e = entries[i];
71
- if (expandedToolIds[e.id])
72
- break;
73
- if (e.kind === 'assistant' && e.streaming)
74
- break;
75
- if (e.kind === 'tool' && e.status !== 'done' && e.status !== 'error')
76
- break;
77
- // A live dispatch is not settled: its `runs` are rewritten on every
78
- // `teamUpdate`, and `<Static>` cannot un-print what it has already drawn.
79
- // The boundary is held MONOTONIC above, which is also why a `team` entry
80
- // that never settles would be re-rendered on every frame for the rest of the
81
- // session — the failure `session/persist.ts` normalizes away on load (P1-5).
82
- if (e.kind === 'team' && e.active)
83
- break;
84
- // A live todo card is not settled either: it is rewritten on every
85
- // `todoUpdate` for the whole turn, and `<Static>` cannot un-print what it has
86
- // already drawn (C-5 / I-6). `session/persist.ts` forces `live: false` on
87
- // load, which is the other half — a card that never settles is re-rendered
88
- // on every frame for the rest of the session.
89
- if (e.kind === 'todo' && e.live)
90
- break;
91
- // Nor is a retry card that is still counting down or still in flight: it is
92
- // rewritten on every `retryScheduled` / `retryAttempt` and once a second by
93
- // `retryTick`, and `<Static>` cannot un-print what it has already drawn.
94
- // `session/persist.ts` forces a loaded card to `interrupted`, which is the
95
- // other half — a card that never settles is re-rendered on every frame for
96
- // the rest of the session (R-10).
97
- if (e.kind === 'retry' && (e.phase === 'waiting' || e.phase === 'retrying'))
98
- break;
99
- // A live review card is not settled either: it is rewritten once, when the
100
- // call comes back, and `<Static>` cannot un-print what it has already drawn
101
- // (C-5). `session/persist.ts` forces `live: false` on load, which is the
102
- // other half.
103
- if (e.kind === 'fast' && e.live)
104
- break;
105
- // Nor is a compaction card whose call is still open: it is rewritten once,
106
- // when the engine reports its verdict, and `<Static>` cannot un-print what it
107
- // has already drawn (C-8). `session/persist.ts` forces `live: false` on load,
108
- // which is the other half.
109
- if (e.kind === 'compaction' && e.live)
110
- break;
111
- /**
112
- * A SERVICE BLOCKS ONLY WHILE IT IS `starting` (D-11 / P0-4).
113
- *
114
- * Every clause above is bounded by an OPERATION — a dispatch, a turn, one
115
- * LLM call, one countdown — which is why each of them can safely pin this
116
- * prefix scan: the wait is short and it ends. A service is bounded by the
117
- * USER'S INTENT. A dev server left up for an hour is the normal case, not
118
- * the pathological one, so treating it like the other five would pin the
119
- * boundary at the card and re-render the whole transcript from there on
120
- * EVERY FRAME, FOREVER, on exactly the long sessions where it is least
121
- * likely to be noticed.
122
- *
123
- * `starting` is different, and it is different for the reason that makes
124
- * every other clause here legitimate: it is bounded by `readyTimeoutMs`,
125
- * after which the supervisor calls the service `running` whatever happened.
126
- *
127
- * The cost of the rest is that a printed card stops mirroring live state —
128
- * which is what a transcript IS. A terminal transition appends a NEW one-row
129
- * entry rather than rewriting a card `<Static>` has already drawn, and live
130
- * state lives in the status chip, `/bg` and `bash_output`. Weakening this to
131
- * "any non-terminal service blocks" is the change D-11 exists to prevent;
132
- * see condition 5 of the design's review verdict before making it.
133
- */
134
- if (e.kind === 'service' && e.status === 'starting')
135
- break;
136
- count = i + 1;
137
- }
138
- return count;
139
- }
140
24
  /**
141
25
  * The value `EntryView` should receive for `nowSec` on THIS entry.
142
26
  *
@@ -168,40 +52,27 @@ function noticeSymbol(level, theme) {
168
52
  return theme.symbols.warn;
169
53
  return theme.symbols.info;
170
54
  }
171
- function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion, density, mode, theme, caps, liveClampRows, nowSec, }) {
55
+ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion, density, theme, caps, nowSec, }) {
172
56
  const glyphs = pickGlyphs(caps);
173
57
  const separation = separationRows(prev, entry, density);
174
58
  const frame = (glyph, color, children) => (_jsx(EntryFrame, { glyph: glyph, color: color, continuation: glyphs.railVertical, separation: separation, spacerColor: theme.border, children: children }));
175
59
  switch (entry.kind) {
176
60
  case 'user':
177
61
  return frame(glyphs.user, theme.user, _jsx(UserEntry, { text: entry.text, theme: theme, caps: caps }));
62
+ case 'queued':
63
+ // `theme.muted`: quieter than a user message, louder than a notice --
64
+ // the row exists to be noticed UNTIL it is accepted, then rewritten in
65
+ // place by `turnStart` (5.2.3).
66
+ return frame(glyphs.queued, theme.muted, _jsx(QueuedEntry, { text: entry.text, theme: theme, caps: caps }));
178
67
  case 'assistant': {
179
- // While text streams, the spinner IS the role marker. Braille dots are
180
- // both an animation and a Unicode-only glyph, so reduced motion and an
181
- // ASCII terminal fall back to the same static marker (P2-11 / A-10).
182
- //
183
- // `reducedMotion` ARRIVES PRE-WIDENED FROM `App`: it is now
184
- // `cfg.reducedMotion || activityVisible`, so this marker is static for the
185
- // whole of every run and the activity row above the composer carries the
186
- // one animation (single-spinner-while-running D-2). Nothing here changes —
187
- // the expression already meant "do not animate here".
188
- //
189
- // THAT IS SAFE FOR `<Static>` BY CONSTRUCTION, not by luck: a settled
190
- // prefix is printed once and can never be re-printed, so a boolean that
191
- // changed how an already-printed entry renders would tear the history.
192
- // `computeSettledCount` below breaks on EVERY condition that makes an
193
- // entry animate (`:84` streaming, `:85` running tool, `:91` active team,
194
- // `:104` retry, `:109` live fast), so every animating entry is in the live
195
- // region and every entry in `<Static>` already has `animate === false` on
196
- // both sides of the flip. Weakening a break clause breaks that proof.
197
68
  const animate = entry.streaming && !reducedMotion && caps.unicode;
198
69
  return frame(animate ? _jsx(Spinner, { type: "dots" }) : glyphs.assistant, theme.primary, _jsx(AssistantEntry, { text: entry.text, thinking: entry.thinking, thinkingOpen: entry.thinkingOpen, thinkingVisible: thinkingVisible, streaming: entry.streaming, aborted: entry.aborted,
199
70
  // `EntryViewImpl` hands each renderer EXPLICIT SCALAR PROPS, so
200
71
  // nothing on the entry reaches a component by itself (P1-6b).
201
- thinkingMs: entry.thinkingMs, revealable: mode === 'fullscreen', liveClampRows: liveClampRows, theme: theme, caps: caps }));
72
+ thinkingMs: entry.thinkingMs, theme: theme, caps: caps }));
202
73
  }
203
74
  case 'tool':
204
- return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(ToolCard, { name: entry.name, label: entry.label, argsRaw: entry.argsRaw, args: entry.args, status: entry.status, preview: entry.preview, patch: entry.patch, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion, liveClampRows: liveClampRows,
75
+ return frame(toolEntryGlyph(entry.name, caps), statusColor(entry.status, theme), _jsx(ToolCard, { name: entry.name, label: entry.label, argsRaw: entry.argsRaw, args: entry.args, status: entry.status, preview: entry.preview, patch: entry.patch, durationMs: entry.durationMs, isError: entry.isError, expanded: expanded, reducedMotion: reducedMotion,
205
76
  // `entry.live` AND NOT `entry.live ?? []` (P2-4): `ToolCard`'s default
206
77
  // comparator sees a fresh `[]` as a changed prop, which is why that
207
78
  // file already keeps a shared `EMPTY_LINES`.
@@ -263,10 +134,8 @@ export const EntryView = React.memo(EntryViewImpl, (a, b) => {
263
134
  a.thinkingVisible === b.thinkingVisible &&
264
135
  a.reducedMotion === b.reducedMotion &&
265
136
  a.density === b.density &&
266
- a.mode === b.mode &&
267
137
  a.theme === b.theme &&
268
138
  a.caps === b.caps &&
269
- a.liveClampRows === b.liveClampRows &&
270
139
  /*
271
140
  * ELEVENTH TERM, AND OMITTING IT IS THE TRAP I-L2-1 DESCRIBES ONE COMMENT UP
272
141
  * (P1-3). This comparator is a CLOSED LIST: a prop absent from it changes
@@ -279,27 +148,7 @@ export const EntryView = React.memo(EntryViewImpl, (a, b) => {
279
148
  */
280
149
  a.nowSec === b.nowSec);
281
150
  });
282
- /**
283
- * Full-screen transcript body — the same entry renderers with NO `<Static>`.
284
- *
285
- * `<Static>` prints above the live frame; once the frame is `rows - 1` tall
286
- * there is exactly one visible line up there, so the mechanism stops being a
287
- * history view and starts being a leak. Full-screen therefore owns its history
288
- * inside `ScrollViewport` instead.
289
- *
290
- * `computeSettledCount` is deliberately NOT called here: its only job is to keep
291
- * an entry that already reached `<Static>` from flowing back into the live
292
- * region, and with no Static there is nothing to protect. The settled high-water
293
- * mark feeds nothing else — `/save` serializes `entries` in full — so leaving it
294
- * un-advanced has no side effect.
295
- *
296
- * VIRTUALISED (L3 / R1). `windowSize` is now the SCROLL HORIZON — how far back
297
- * the user can scroll — and no longer the rendering budget, because entries
298
- * outside the viewport are not mounted at all. What bounds the frame is
299
- * `selectWindow`, and the two spacers are what keep the scroll maths honest
300
- * while they are absent.
301
- */
302
- export function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, mode, nowSec, theme, caps, windowSize, cols, heights, mountedSink, tailSink, }) {
151
+ export function TranscriptList({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, nowSec, theme, caps, windowSize, cols, heights, mountedSink, tailSink, }) {
303
152
  const size = Math.max(1, Math.floor(windowSize));
304
153
  const visible = entries.length > size ? entries.slice(-size) : entries;
305
154
  const collapsed = entries.length - visible.length;
@@ -352,39 +201,6 @@ export function TranscriptList({ entries, expandedToolIds, thinkingVisible, redu
352
201
  }
353
202
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [collapsed > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [glyphs.ellipsis, " ", collapsed, " earlier ", collapsed === 1 ? 'entry' : 'entries', " collapsed (/save exports the full session)"] })), selection.leadingRows > 0 && (_jsx(Box, { flexShrink: 0, height: selection.leadingRows })), slice.map((entry, i) => {
354
203
  const index = selection.startIndex + i;
355
- return (_jsx(MeasuredEntry, { heightKey: keyOf(entry), onMeasure: report, children: _jsx(EntryView, { entry: entry, prev: index > 0 ? visible[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, mode: mode, nowSec: nowSecFor(entry, nowSec), theme: theme, caps: caps }) }, entry.id));
204
+ return (_jsx(MeasuredEntry, { heightKey: keyOf(entry), onMeasure: report, children: _jsx(EntryView, { entry: entry, prev: index > 0 ? visible[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, nowSec: nowSecFor(entry, nowSec), theme: theme, caps: caps }) }, entry.id));
356
205
  }), selection.trailingRows > 0 && (_jsx(Box, { flexShrink: 0, height: selection.trailingRows }))] }));
357
206
  }
358
- export function Transcript({ entries, expandedToolIds, thinkingVisible, reducedMotion, density, mode, nowSec, theme, caps, }) {
359
- const highWater = useRef(0);
360
- const prevLen = useRef(0);
361
- const { rows } = useTerminalSize();
362
- const raw = computeSettledCount(entries, expandedToolIds);
363
- // The transcript shrank (clear / reset / restore) — drop the high-water mark.
364
- if (entries.length < prevLen.current)
365
- highWater.current = 0;
366
- prevLen.current = entries.length;
367
- const settled = Math.min(entries.length, Math.max(highWater.current, raw));
368
- highWater.current = settled;
369
- const settledEntries = entries.slice(0, settled);
370
- const liveEntries = entries.slice(settled);
371
- // I-L5-1 — the live region must stay strictly below `stdout.rows`, or Ink
372
- // takes the `ink.js:118-123` branch and writes `clearTerminal +
373
- // fullStaticOutput + output` on EVERY subsequent frame for the rest of the
374
- // session.
375
- //
376
- // `LIVE_TAIL` is 1, so the common case is the design's `rows - 4` less the one
377
- // row the clamp marker costs. THE DIVISION IS NOT DECORATION: the settled
378
- // boundary is held back by an expanded card, a running tool, a live dispatch
379
- // or a live todo list, and in every one of those cases the region holds
380
- // SEVERAL entries at once — a flat per-entry ceiling of `rows - 4` would then
381
- // overrun by a multiple of itself. `- 1` pays for each entry's marker row.
382
- //
383
- // The residual: an entry contributes at least its header row whatever the
384
- // clamp says, so the bound holds while the live region holds fewer than about
385
- // `rows / 2` entries. Bounding it unconditionally would mean refusing to draw
386
- // a live entry at all, which is worse than a tall frame.
387
- const liveBudget = Math.max(INLINE_LIVE_MIN_ROWS, rows - INLINE_LIVE_MARGIN);
388
- const liveClampRows = Math.max(1, Math.floor(liveBudget / Math.max(1, liveEntries.length)) - 1);
389
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: settledEntries, children: (entry, index) => (_jsx(EntryView, { entry: entry, prev: index > 0 ? settledEntries[index - 1] : undefined, expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, mode: mode, theme: theme, caps: caps }, entry.id)) }), _jsx(Box, { flexDirection: "column", children: liveEntries.map((entry, i) => (_jsx(EntryView, { entry: entry, prev: i > 0 ? liveEntries[i - 1] : entries[settled - 1], expanded: !!expandedToolIds[entry.id], thinkingVisible: thinkingVisible, reducedMotion: reducedMotion, density: density, mode: mode, theme: theme, caps: caps, liveClampRows: liveClampRows, nowSec: nowSecFor(entry, nowSec) }, entry.id))) })] }));
390
- }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Reading Enter frames back out of an Ink `input` string
3
+ * (tui-shift-enter-copy-queue 3.5) -- pure.
4
+ *
5
+ * The stdin filter rewrites a modified Enter into `ENTER_NEWLINE_FRAME` inline,
6
+ * in stream order, exactly the way it frames a paste. This module is the only
7
+ * place that takes those frames apart again, and it handles BOTH newline
8
+ * sources the composer can see in one `input` string:
9
+ *
10
+ * - `ENTER_NEWLINE_FRAME` -- Shift+Enter / Alt+Enter / CSI-u Enter, translated
11
+ * by the filter; and
12
+ * - a bare `\n` -- Ctrl+J, which Ink parses to `name: 'enter'` with
13
+ * `input === '\n'` and NO `key.return`, so it reaches this branch as an
14
+ * ordinary (control) character that must still mean "newline".
15
+ *
16
+ * I-12, THE BROADCAST OBLIGATION, APPLIES TO THESE FRAMES TOO. `useInput`
17
+ * re-emits every chunk to every mounted handler, `stripPasteFrames` does not
18
+ * know this frame, and a NUL appended into `SettingsScreen`'s API-key field
19
+ * renders as nothing and silently breaks authentication -- the exact accident
20
+ * class that function's own doc records. `stripEnterFrames` is the equivalent
21
+ * guard for overlays with no paste model of their own.
22
+ */
23
+ import { type FrameSegment } from './paste-frames.js';
24
+ /** True when this `input` carries at least one Enter frame -- the branch guard. */
25
+ export declare function hasEnterFrame(input: string): boolean;
26
+ /**
27
+ * Split an `input` string into ordered text runs and newline runs.
28
+ *
29
+ * Text-only by construction (paste frames are `mergeWithPasteRuns`'s job), so
30
+ * the paste id never has to exist here -- `PromptInput` allocates ids for paste
31
+ * segments while building the reducer's action, exactly as the paste branch
32
+ * does (P1-5: the reducer stays pure and this stays a function of its input).
33
+ */
34
+ export declare function splitEnterFrames(input: string): FrameSegment[];
35
+ /**
36
+ * Merge newline runs with the paste frames that share the same `input`.
37
+ *
38
+ * Ink drains its buffer in one size-less `read()`, so the filter's TWO writes
39
+ * (one per frame family) can arrive as ONE chunk carrying both a paste frame
40
+ * and an Enter frame. Neither splitter alone preserves that order --
41
+ * `splitPasteFrames` sanitises `\n` out of its text runs, and this module's
42
+ * spans treat a paste payload as ordinary text -- so the merge walks the
43
+ * ORIGINAL string once: paste frames become paste segments (payload through
44
+ * `sanitisePaste`, as `splitPasteFrames` does), and the spans between them are
45
+ * segmented with the SAME walker `splitEnterFrames` uses, which is what keeps
46
+ * the two views from disagreeing about where a newline sits.
47
+ *
48
+ * Paste segments carry NO id here (P1-5); the caller assigns one per segment
49
+ * only when it builds the reducer's action.
50
+ */
51
+ export declare function mergeWithPasteRuns(input: string, runs: FrameSegment[]): FrameSegment[];
52
+ /**
53
+ * Remove every Enter frame for a consumer that has no newline model of its
54
+ * own, replacing each frame with `'\n'` (tui-shift-enter-copy-queue 3.5, the
55
+ * I-12 analogue for this frame family).
56
+ *
57
+ * NO OTHER SANITISATION, unlike `stripPasteFrames`: the overlay call sites wrap
58
+ * this INSIDE their existing `stripPasteFrames(...)` call, and the composite
59
+ * keeps that function's byte-level guarantees. Composed the other way round a
60
+ * lone NUL frame would survive as its payload letter; composed this way the
61
+ * frame's newline lands exactly where a bare Ctrl+J lands today.
62
+ */
63
+ export declare function stripEnterFrames(input: string): string;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Reading Enter frames back out of an Ink `input` string
3
+ * (tui-shift-enter-copy-queue 3.5) -- pure.
4
+ *
5
+ * The stdin filter rewrites a modified Enter into `ENTER_NEWLINE_FRAME` inline,
6
+ * in stream order, exactly the way it frames a paste. This module is the only
7
+ * place that takes those frames apart again, and it handles BOTH newline
8
+ * sources the composer can see in one `input` string:
9
+ *
10
+ * - `ENTER_NEWLINE_FRAME` -- Shift+Enter / Alt+Enter / CSI-u Enter, translated
11
+ * by the filter; and
12
+ * - a bare `\n` -- Ctrl+J, which Ink parses to `name: 'enter'` with
13
+ * `input === '\n'` and NO `key.return`, so it reaches this branch as an
14
+ * ordinary (control) character that must still mean "newline".
15
+ *
16
+ * I-12, THE BROADCAST OBLIGATION, APPLIES TO THESE FRAMES TOO. `useInput`
17
+ * re-emits every chunk to every mounted handler, `stripPasteFrames` does not
18
+ * know this frame, and a NUL appended into `SettingsScreen`'s API-key field
19
+ * renders as nothing and silently breaks authentication -- the exact accident
20
+ * class that function's own doc records. `stripEnterFrames` is the equivalent
21
+ * guard for overlays with no paste model of their own.
22
+ */
23
+ import { ENTER_NEWLINE_FRAME, PASTE_CLOSE, PASTE_OPEN } from '../input/limits.js';
24
+ import { sanitisePaste } from '../input/paste-parse.js';
25
+ import { sanitiseTyped } from './paste-frames.js';
26
+ /** True when this `input` carries at least one Enter frame -- the branch guard. */
27
+ export function hasEnterFrame(input) {
28
+ return input.includes(ENTER_NEWLINE_FRAME);
29
+ }
30
+ /**
31
+ * Split one Enter-frame-free span into ordered text / newline segments.
32
+ *
33
+ * Every newline source -- frame or bare `\n` -- yields ONE
34
+ * `{ kind: 'text', text: '\n' }` segment, so the editor's existing `input`
35
+ * action inserts it through the same path typing uses and the draft's row
36
+ * budget applies unchanged. Non-newline text is accumulated and passed through
37
+ * `sanitiseTyped` (I-14): a chunk that carries a frame can also carry the
38
+ * keystrokes that arrived beside it, and a coalesced CR must not be spliced
39
+ * back into the draft raw.
40
+ */
41
+ function splitEnterSpan(span) {
42
+ const segments = [];
43
+ let text = '';
44
+ const pushText = () => {
45
+ const clean = sanitiseTyped(text);
46
+ if (clean.length > 0)
47
+ segments.push({ kind: 'text', text: clean });
48
+ text = '';
49
+ };
50
+ let i = 0;
51
+ while (i < span.length) {
52
+ if (span.startsWith(ENTER_NEWLINE_FRAME, i)) {
53
+ pushText();
54
+ segments.push({ kind: 'text', text: '\n' });
55
+ i += ENTER_NEWLINE_FRAME.length;
56
+ continue;
57
+ }
58
+ if (span[i] === '\n') {
59
+ pushText();
60
+ segments.push({ kind: 'text', text: '\n' });
61
+ i += 1;
62
+ continue;
63
+ }
64
+ text += span[i];
65
+ i += 1;
66
+ }
67
+ pushText();
68
+ return segments;
69
+ }
70
+ /**
71
+ * Split an `input` string into ordered text runs and newline runs.
72
+ *
73
+ * Text-only by construction (paste frames are `mergeWithPasteRuns`'s job), so
74
+ * the paste id never has to exist here -- `PromptInput` allocates ids for paste
75
+ * segments while building the reducer's action, exactly as the paste branch
76
+ * does (P1-5: the reducer stays pure and this stays a function of its input).
77
+ */
78
+ export function splitEnterFrames(input) {
79
+ return splitEnterSpan(input);
80
+ }
81
+ /**
82
+ * Merge newline runs with the paste frames that share the same `input`.
83
+ *
84
+ * Ink drains its buffer in one size-less `read()`, so the filter's TWO writes
85
+ * (one per frame family) can arrive as ONE chunk carrying both a paste frame
86
+ * and an Enter frame. Neither splitter alone preserves that order --
87
+ * `splitPasteFrames` sanitises `\n` out of its text runs, and this module's
88
+ * spans treat a paste payload as ordinary text -- so the merge walks the
89
+ * ORIGINAL string once: paste frames become paste segments (payload through
90
+ * `sanitisePaste`, as `splitPasteFrames` does), and the spans between them are
91
+ * segmented with the SAME walker `splitEnterFrames` uses, which is what keeps
92
+ * the two views from disagreeing about where a newline sits.
93
+ *
94
+ * Paste segments carry NO id here (P1-5); the caller assigns one per segment
95
+ * only when it builds the reducer's action.
96
+ */
97
+ export function mergeWithPasteRuns(input, runs) {
98
+ if (!input.includes(PASTE_OPEN))
99
+ return runs;
100
+ const merged = [];
101
+ let i = 0;
102
+ while (i < input.length) {
103
+ const open = input.indexOf(PASTE_OPEN, i);
104
+ if (open === -1) {
105
+ merged.push(...splitEnterSpan(input.slice(i)));
106
+ break;
107
+ }
108
+ merged.push(...splitEnterSpan(input.slice(i, open)));
109
+ const bodyStart = open + PASTE_OPEN.length;
110
+ const close = input.indexOf(PASTE_CLOSE, bodyStart);
111
+ const body = close === -1 ? input.slice(bodyStart) : input.slice(bodyStart, close);
112
+ const payload = sanitisePaste(body);
113
+ if (payload.length > 0)
114
+ merged.push({ kind: 'paste', text: payload });
115
+ if (close === -1)
116
+ break;
117
+ i = close + PASTE_CLOSE.length;
118
+ }
119
+ return merged;
120
+ }
121
+ /**
122
+ * Remove every Enter frame for a consumer that has no newline model of its
123
+ * own, replacing each frame with `'\n'` (tui-shift-enter-copy-queue 3.5, the
124
+ * I-12 analogue for this frame family).
125
+ *
126
+ * NO OTHER SANITISATION, unlike `stripPasteFrames`: the overlay call sites wrap
127
+ * this INSIDE their existing `stripPasteFrames(...)` call, and the composite
128
+ * keeps that function's byte-level guarantees. Composed the other way round a
129
+ * lone NUL frame would survive as its payload letter; composed this way the
130
+ * frame's newline lands exactly where a bare Ctrl+J lands today.
131
+ */
132
+ export function stripEnterFrames(input) {
133
+ if (!hasEnterFrame(input))
134
+ return input;
135
+ return input.split(ENTER_NEWLINE_FRAME).join('\n');
136
+ }
@@ -29,29 +29,6 @@ interface AssistantEntryProps {
29
29
  * `thought for 0s` is a lie where a bare `thought` is merely terse.
30
30
  */
31
31
  thinkingMs?: number;
32
- /**
33
- * Whether pressing `Ctrl+T` would actually reveal THIS entry's body — i.e.
34
- * `mode === 'fullscreen'` (D-16 / P0-2).
35
- *
36
- * In inline mode a settled entry has already been printed into Ink's
37
- * `<Static>`, which cannot un-print or re-print (`Transcript.tsx:9-10`), and
38
- * the settled boundary is held monotonic precisely so an entry never flows back
39
- * out. With thinking hidden by default the body is never printed there AT ALL,
40
- * so a row reading `ctrl+t to show` would be an instruction that provably does
41
- * nothing. The row degrades to `* thought for 12s` instead — still honest that
42
- * reasoning happened and how long it took, and silent about a key that cannot
43
- * deliver.
44
- */
45
- revealable?: boolean;
46
- /**
47
- * Inline mode only (L5): keep at most this many rows of a LIVE entry, so the
48
- * non-`<Static>` region cannot reach `stdout.rows`. `undefined` means no
49
- * clamp on the ANSWER BODY, which is what the full-screen branch passes — so
50
- * the rendered answer there is byte-identical to a pre-feature build. (The
51
- * thinking block has its own streaming clamp below and is not covered by that
52
- * statement.)
53
- */
54
- liveClampRows?: number;
55
32
  theme: Theme;
56
33
  caps: TermCapabilities;
57
34
  }
@@ -22,7 +22,7 @@ import { splitLinesCached } from '../render-cache.js';
22
22
  import { clampLiveText } from '../live-clamp.js';
23
23
  import { formatDuration } from '../../agent/usage.js';
24
24
  function AssistantEntryImpl(props) {
25
- const { text, thinking, thinkingOpen, thinkingVisible, streaming, aborted, thinkingMs, revealable, liveClampRows, theme, caps, } = props;
25
+ const { text, thinking, thinkingOpen, thinkingVisible, streaming, aborted, thinkingMs, theme, caps, } = props;
26
26
  const glyphs = pickGlyphs(caps);
27
27
  const hasThinking = !!thinking && thinking.trim().length > 0;
28
28
  const hasText = text.trim().length > 0;
@@ -32,12 +32,11 @@ function AssistantEntryImpl(props) {
32
32
  // full-screen mode: it is scratch reasoning the user reads the newest end of,
33
33
  // and an unbounded one is the single easiest way to push a live entry past the
34
34
  // whole viewport.
35
- const thinkingRows = liveClampRows ?? (streaming ? STREAMING_THINKING_ROWS : undefined);
35
+ const thinkingRows = streaming ? STREAMING_THINKING_ROWS : undefined;
36
36
  const clampedThinking = hasThinking && thinkingRows !== undefined
37
37
  ? clampLiveText(thinking, thinkingRows)
38
38
  : { text: thinking ?? '', hiddenRows: 0 };
39
- const body = liveClampRows === undefined ? { text, hiddenRows: 0 } : clampLiveText(text, liveClampRows);
40
- return (_jsxs(Box, { flexDirection: "column", children: [hasThinking && thinkingVisible && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [glyphs.thinking, " thinking", thinkingOpen ? glyphs.ellipsis : ''] }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [clampedThinking.hiddenRows > 0 && (_jsxs(Text, { color: theme.muted, children: [glyphs.ellipsis, " ", clampedThinking.hiddenRows, " earlier", ' ', clampedThinking.hiddenRows === 1 ? 'line' : 'lines'] })), splitLinesCached(clampedThinking.text).map((line, i) => (_jsx(Text, { color: theme.thinking, dimColor: true, children: line }, i)))] })] })), hasThinking && !thinkingVisible && !streaming && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.thinking, " thought", thinkingMs !== undefined ? ` for ${formatDuration(thinkingMs)}` : '', revealable ? ` ${glyphs.midDot} ctrl+t to show` : ''] })), (hasText || streaming) && (_jsxs(Box, { flexDirection: "column", children: [body.hiddenRows > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.ellipsis, " ", body.hiddenRows, " earlier", ' ', body.hiddenRows === 1 ? 'line' : 'lines', " ", glyphs.midDot, " shown in full when this entry finishes"] })), hasText ? (_jsx(Markdown, { text: body.text, theme: theme, caps: caps })) : (_jsx(Text, { color: theme.muted, children: glyphs.ellipsis })), aborted && _jsx(Text, { color: theme.noticeWarn, children: "[aborted]" })] }))] }));
39
+ return (_jsxs(Box, { flexDirection: "column", children: [hasThinking && thinkingVisible && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [glyphs.thinking, " thinking", thinkingOpen ? glyphs.ellipsis : ''] }), _jsxs(Box, { flexDirection: "column", marginLeft: 2, children: [clampedThinking.hiddenRows > 0 && (_jsxs(Text, { color: theme.muted, children: [glyphs.ellipsis, " ", clampedThinking.hiddenRows, " earlier", ' ', clampedThinking.hiddenRows === 1 ? 'line' : 'lines'] })), splitLinesCached(clampedThinking.text).map((line, i) => (_jsx(Text, { color: theme.thinking, dimColor: true, children: line }, i)))] })] })), hasThinking && !thinkingVisible && !streaming && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.thinking, " thought", thinkingMs !== undefined ? ` for ${formatDuration(thinkingMs)}` : '', ` ${glyphs.midDot} ctrl+t to show`] })), (hasText || streaming) && (_jsxs(Box, { flexDirection: "column", children: [hasText ? (_jsx(Markdown, { text: text, theme: theme, caps: caps })) : (_jsx(Text, { color: theme.muted, children: glyphs.ellipsis })), aborted && _jsx(Text, { color: theme.noticeWarn, children: "[aborted]" })] }))] }));
41
40
  }
42
41
  /** Rows of live thinking kept on screen while an answer streams. */
43
42
  const STREAMING_THINKING_ROWS = 24;
@@ -32,12 +32,6 @@ export declare const DIFF_COLLAPSED_LINES = 12;
32
32
  export interface DiffViewProps {
33
33
  patch: FilePatch;
34
34
  expanded?: boolean;
35
- /**
36
- * Inline mode only (L5): ceiling on the rows a LIVE card contributes to the
37
- * non-`<Static>` region. `undefined` — what the full-screen branch passes —
38
- * means no clamp.
39
- */
40
- liveClampRows?: number;
41
35
  theme: Theme;
42
36
  }
43
37
  /** One rendered row: a patch line, or the `@@` separator between two hunks. */
@@ -56,7 +50,7 @@ interface DiffRow {
56
50
  * disagree.
57
51
  */
58
52
  export declare function diffRows(patch: FilePatch): DiffRow[];
59
- declare function DiffViewImpl({ patch, expanded, liveClampRows, theme, }: DiffViewProps): React.ReactElement;
53
+ declare function DiffViewImpl({ patch, expanded, theme, }: DiffViewProps): React.ReactElement;
60
54
  /**
61
55
  * `React.memo` with the DEFAULT comparator, matching `ToolCard`'s boundary.
62
56
  * `patch` qualifies as a prop only because the reducer writes it ONCE at
@@ -86,9 +86,9 @@ function gutterWidth(rows) {
86
86
  max = row.num;
87
87
  return Math.min(GUTTER_MAX, Math.max(GUTTER_MIN, String(max).length));
88
88
  }
89
- function DiffViewImpl({ patch, expanded, liveClampRows, theme, }) {
89
+ function DiffViewImpl({ patch, expanded, theme, }) {
90
90
  const rows = diffRows(patch);
91
- const ceiling = Math.min(expanded ? rows.length : DIFF_COLLAPSED_LINES, liveClampRows ?? Number.POSITIVE_INFINITY);
91
+ const ceiling = expanded ? rows.length : DIFF_COLLAPSED_LINES;
92
92
  const visible = ceiling >= rows.length ? rows : rows.slice(0, Math.max(0, ceiling));
93
93
  const hidden = rows.length - visible.length;
94
94
  const width = gutterWidth(rows);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Render a queued steering message (tui-shift-enter-copy-queue 5.4).
3
+ *
4
+ * ONE ROW, ALWAYS. `wrap="truncate"` -- the run-status-row width policy, for
5
+ * the height-estimate reason DiffView learned the hard way: a queued message
6
+ * can be arbitrarily long, and an entry whose wrapped height disagrees with its
7
+ * estimate is exactly the one the virtual window never re-measures.
8
+ *
9
+ * The glyph and the rail belong to `EntryFrame`, exactly as for every other
10
+ * entry; this component renders content only.
11
+ */
12
+ import React from 'react';
13
+ import type { Theme } from '../theme.js';
14
+ import type { TermCapabilities } from '../capabilities.js';
15
+ declare function QueuedEntryImpl({ text, theme, caps, }: {
16
+ text: string;
17
+ theme: Theme;
18
+ caps: TermCapabilities;
19
+ }): React.ReactElement;
20
+ /**
21
+ * `React.memo` with the DEFAULT comparator, like every sibling entry card: the
22
+ * reducer rebuilds the entry object only when it changes, and a `queued` entry
23
+ * changes exactly twice -- once when appended, once when it is rewritten into a
24
+ * `user` entry and stops being this component's problem.
25
+ */
26
+ export declare const QueuedEntry: React.MemoExoticComponent<typeof QueuedEntryImpl>;
27
+ export {};
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Render a queued steering message (tui-shift-enter-copy-queue 5.4).
4
+ *
5
+ * ONE ROW, ALWAYS. `wrap="truncate"` -- the run-status-row width policy, for
6
+ * the height-estimate reason DiffView learned the hard way: a queued message
7
+ * can be arbitrarily long, and an entry whose wrapped height disagrees with its
8
+ * estimate is exactly the one the virtual window never re-measures.
9
+ *
10
+ * The glyph and the rail belong to `EntryFrame`, exactly as for every other
11
+ * entry; this component renders content only.
12
+ */
13
+ import React from 'react';
14
+ import { Text } from 'ink';
15
+ import { pickGlyphs } from '../glyphs.js';
16
+ function QueuedEntryImpl({ text, theme, caps, }) {
17
+ const glyphs = pickGlyphs(caps);
18
+ const lines = text.split('\n');
19
+ const first = lines[0] ?? '';
20
+ const extra = lines.length - 1;
21
+ const suffix = extra > 0 ? ` ${glyphs.ellipsis}+${extra} line${extra === 1 ? '' : 's'}` : '';
22
+ return (_jsx(Text, { wrap: "truncate", color: theme.muted, children: `Queue: ${first}${suffix}` }));
23
+ }
24
+ /**
25
+ * `React.memo` with the DEFAULT comparator, like every sibling entry card: the
26
+ * reducer rebuilds the entry object only when it changes, and a `queued` entry
27
+ * changes exactly twice -- once when appended, once when it is rewritten into a
28
+ * `user` entry and stops being this component's problem.
29
+ */
30
+ export const QueuedEntry = React.memo(QueuedEntryImpl);
@@ -37,12 +37,6 @@ interface ToolCardProps {
37
37
  isError?: boolean;
38
38
  expanded?: boolean;
39
39
  reducedMotion?: boolean;
40
- /**
41
- * Inline mode only (L5): ceiling on the rows a LIVE card contributes to the
42
- * non-`<Static>` region. `undefined` — what the full-screen branch passes —
43
- * means no clamp and byte-identical output.
44
- */
45
- liveClampRows?: number;
46
40
  /**
47
41
  * The sanitised live tail while the tool runs
48
42
  * (agent-activity-presentation-live §3.3.4).