pum-agent 0.2.22-beta.1 → 0.2.24-beta.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.22-beta.1",
3
+ "version": "0.2.24-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/animation.tsx CHANGED
@@ -285,13 +285,16 @@ export function AnimationProvider({
285
285
  );
286
286
  const workingRuleCycleWidth = useCallback(() => workingCycleWidth.current, []);
287
287
 
288
- return (
289
- <ClockContext.Provider
290
- value={{ subscribe, workingElapsed, workingRuleCycleWidth, enabled }}
291
- >
292
- {children}
293
- </ClockContext.Provider>
288
+ // A fresh object here would be a changed context value on every render of the
289
+ // app, and React answers that by walking the whole tree below the provider to
290
+ // find consumers. With a long transcript that walk is the largest part of the
291
+ // cost of one keystroke, so the value keeps its identity while its parts do.
292
+ const clock = useMemo(
293
+ () => ({ subscribe, workingElapsed, workingRuleCycleWidth, enabled }),
294
+ [subscribe, workingElapsed, workingRuleCycleWidth, enabled],
294
295
  );
296
+
297
+ return <ClockContext.Provider value={clock}>{children}</ClockContext.Provider>;
295
298
  }
296
299
 
297
300
  /** Exported for the test that guards the run-coalescing loop below. */
package/src/app.tsx CHANGED
@@ -3,13 +3,14 @@ import {
3
3
  stripAnsiSequences,
4
4
  type PasteEvent,
5
5
  type ScrollBoxRenderable,
6
+ type SyntaxStyle,
6
7
  type TextareaRenderable,
7
8
  } from "@opentui/core";
8
9
  import { randomUUID } from "node:crypto";
9
10
  import { useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
10
11
  import { getSupportedThinkingLevels, type Model } from "@earendil-works/pi-ai";
11
12
  import type { AgentSession, BashOperations, ModelRuntime } from "@earendil-works/pi-coding-agent";
12
- import { Component, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
13
+ import { Component, memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
13
14
  import {
14
15
  AnimationProvider,
15
16
  supportsTrueColor,
@@ -87,7 +88,11 @@ import {
87
88
  webSearch,
88
89
  withSearchRoute,
89
90
  } from "./web-search";
90
- import { isCommandInput, matchingCommands, moveCommandSelection } from "./commands";
91
+ import {
92
+ isCommandInput,
93
+ matchingCommandsForTarget,
94
+ moveCommandSelection,
95
+ } from "./commands";
91
96
  import { truncateStatusText } from "./status-metadata";
92
97
  import { modeLineLabels } from "./mode-line";
93
98
  import { RULE_LABEL_TRAILING_RULE_COLUMNS } from "./goal-line";
@@ -114,6 +119,7 @@ import {
114
119
  } from "./session-settings";
115
120
  import { AfkController, type AfkStatus } from "./afk";
116
121
  import { parseAfkCommand } from "./afk-command";
122
+ import { parseBackgroundCommand } from "./background-command";
117
123
  import {
118
124
  afkAnswerFailureText,
119
125
  buildAfkTask,
@@ -262,9 +268,20 @@ import {
262
268
  projectPendingTranscriptLines,
263
269
  projectTranscriptLines,
264
270
  transcriptOutputMode,
271
+ type TranscriptOutputMode,
265
272
  } from "./transcript-output";
266
273
  import type { MinimalTranscriptLine } from "./output-minimal";
267
274
  import { heldTranscriptLines, type DwellMemory } from "./transcript-dwell";
275
+ import {
276
+ atWindowBottom,
277
+ atWindowTop,
278
+ clampWindowStart,
279
+ extendedWindowStart,
280
+ nearWindowTop,
281
+ tailWindowStart,
282
+ transcriptWindowRows,
283
+ windowStartForRow,
284
+ } from "./transcript-window";
268
285
 
269
286
  type Stream = { kind: "assistant" | "thinking"; text: string } | null;
270
287
  type Transcript = { lines: Line[]; stream: Stream; pending: PendingLine[] };
@@ -291,6 +308,18 @@ function projectedLineRawText(line: MinimalTranscriptLine): string {
291
308
 
292
309
  const QUIT_WINDOW_MS = 2000;
293
310
  const MAX_INPUT_ROWS = 8;
311
+ /** How long a scroll to a row waits between tries for React to draw it. */
312
+ const ROW_DRAW_RETRY_MS = 30;
313
+ /**
314
+ * How many of those tries it makes before it gives up.
315
+ *
316
+ * A busy machine with a long session can take most of a second to draw a row
317
+ * that had to be mounted first, and a jump that gives up before then looks to
318
+ * the reader exactly like a dead key.
319
+ */
320
+ const ROW_DRAW_TRIES = 30;
321
+ /** Frames a scroll correction waits for its rows before it gives up. */
322
+ const ANCHOR_FRAME_BUDGET = 30;
294
323
  /** Keys that move around without changing the text. */
295
324
  const NAV_KEYS = new Set(["up", "down", "left", "right", "home", "end", "pageup", "pagedown"]);
296
325
 
@@ -391,6 +420,91 @@ export function promptPlaceholder(options: {
391
420
  /** A blank row. An empty <text> measures to nothing, so this needs a height. */
392
421
  const Gap = () => <box style={{ height: 1, flexShrink: 0 }} />;
393
422
 
423
+ /**
424
+ * One rendered transcript row.
425
+ *
426
+ * Memoized on purpose. The transcript is a child of the same component that
427
+ * holds the prompt draft, so without this every keystroke re-rendered every
428
+ * row, and the cost of a keypress grew with the length of the session. Each
429
+ * prop here must therefore stay identity-stable while the row is unchanged:
430
+ * pass the row index and one shared handler rather than a fresh closure.
431
+ */
432
+ const TranscriptRow = memo(function TranscriptRow({
433
+ theme,
434
+ syntaxStyle,
435
+ line,
436
+ index,
437
+ selected,
438
+ expanded,
439
+ outputMode,
440
+ workingCaret,
441
+ gapBefore,
442
+ news,
443
+ onDisclosure,
444
+ }: {
445
+ theme: Theme;
446
+ syntaxStyle: SyntaxStyle;
447
+ line: MinimalTranscriptLine;
448
+ index: number;
449
+ selected: boolean;
450
+ expanded: boolean;
451
+ outputMode: TranscriptOutputMode;
452
+ workingCaret: boolean;
453
+ gapBefore: boolean;
454
+ news?: "seen" | "unseen";
455
+ onDisclosure: (index: number) => void;
456
+ }) {
457
+ const onDisclosureClick = () => onDisclosure(index);
458
+ const row =
459
+ line.kind === "tool-summary" ? (
460
+ <ActivitySummaryLine
461
+ theme={theme}
462
+ syntaxStyle={syntaxStyle}
463
+ summary={line}
464
+ expanded={expanded}
465
+ outputMode={outputMode}
466
+ onDisclosureClick={onDisclosureClick}
467
+ />
468
+ ) : line.kind === "tool" ? (
469
+ <ToolLine
470
+ theme={theme}
471
+ syntaxStyle={syntaxStyle}
472
+ call={line.call}
473
+ workingCaret={workingCaret}
474
+ outputMode={outputMode}
475
+ expanded={expanded}
476
+ onDisclosureClick={onDisclosureClick}
477
+ />
478
+ ) : line.kind === "agent-message" ? (
479
+ <AgentMessageLine theme={theme} syntaxStyle={syntaxStyle} line={line} />
480
+ ) : line.kind === "goal-review" ? (
481
+ <GoalReviewLine theme={theme} line={line} />
482
+ ) : (
483
+ <TextLine
484
+ theme={theme}
485
+ syntaxStyle={syntaxStyle}
486
+ role={line.role as Role}
487
+ text={line.text}
488
+ workingCaret={workingCaret}
489
+ news={news}
490
+ />
491
+ );
492
+ return (
493
+ <box
494
+ id={`transcript-line-${index}`}
495
+ style={{
496
+ flexDirection: "column",
497
+ width: "100%",
498
+ flexShrink: 0,
499
+ backgroundColor: selected ? theme.selectionBg : "transparent",
500
+ }}
501
+ >
502
+ {gapBefore ? <Gap /> : null}
503
+ {row}
504
+ </box>
505
+ );
506
+ });
507
+
394
508
  type RenderErrorBoundaryProps = {
395
509
  theme: Theme;
396
510
  label: string;
@@ -808,6 +922,12 @@ export function App({
808
922
  const transcriptCursorRef = useRef(0);
809
923
  const [detailOverrides, setDetailOverrides] = useState<Map<string, boolean>>(() => new Map());
810
924
  const detailOverridesRef = useRef(detailOverrides);
925
+ // Disclosure clicks reach the memoized rows through one stable function, so a
926
+ // re-render of the app cannot invalidate every row by handing it a new one.
927
+ const clickTranscriptDisclosureRef = useRef<(index: number) => void>(() => {});
928
+ const onTranscriptDisclosure = useRef(
929
+ (index: number) => clickTranscriptDisclosureRef.current(index),
930
+ ).current;
811
931
  // Mirrors settings for update(): a keypress or an async .then can fire a
812
932
  // second update before React commits the first, so update() must build the
813
933
  // next value from the latest pending settings, not the render closure.
@@ -924,9 +1044,13 @@ export function App({
924
1044
  const todoVisible = todoOpen
925
1045
  && !settingsOpen && !helpOpen && !historyOpen && !statsOpen && !agentSelectorOpen
926
1046
  && !triggersOpen && !loginOpen && !newsOpen && !visibleQuestionnaire && !spawnPreview;
927
- const visibleTx = transcriptForThinkingVisibility(
928
- activeAgent?.transcript ?? tx,
929
- settings.showThinking,
1047
+ // Memoized because the filtered result is a new object every call whenever
1048
+ // the transcript holds reasoning. That new identity would re-run the dwell
1049
+ // and projection passes below on every render, keystrokes included.
1050
+ const sourceTx = activeAgent?.transcript ?? tx;
1051
+ const visibleTx = useMemo(
1052
+ () => transcriptForThinkingVisibility(sourceTx, settings.showThinking),
1053
+ [sourceTx, settings.showThinking],
930
1054
  );
931
1055
  const outputMode = transcriptOutputMode(settings);
932
1056
  const showAgentMessages = settings.showAgentMessages !== false;
@@ -958,10 +1082,79 @@ export function App({
958
1082
  () => projectTranscriptLines(held.lines, outputMode, showAgentMessages),
959
1083
  [held, outputMode, showAgentMessages],
960
1084
  );
1085
+ // The rows as they are drawn. A callback that has to find a row needs these,
1086
+ // not the transcript lines: folding and hidden kinds mean the two lists have
1087
+ // different lengths, so a line index is not a row index.
1088
+ const visibleLinesRef = useRef(visibleLines);
1089
+ visibleLinesRef.current = visibleLines;
961
1090
  const visiblePending = useMemo(
962
1091
  () => projectPendingTranscriptLines(visibleTx.pending, showAgentMessages),
963
1092
  [visibleTx.pending, showAgentMessages],
964
1093
  );
1094
+
1095
+ // Which rows are mounted. See `transcript-window.ts` for the rules; these
1096
+ // three refs are the state they run on.
1097
+ //
1098
+ // The start is derived during render rather than stored in state, so a
1099
+ // resumed session mounts its tail on the first render instead of mounting
1100
+ // everything and then trimming. Both derivations are idempotent, so a
1101
+ // repeated render cannot walk the window anywhere.
1102
+ const transcriptWindowStartRef = useRef(0);
1103
+ /** True while the last row is on screen. Only then may the window advance. */
1104
+ const transcriptAtBottomRef = useRef(true);
1105
+ /** Lowest start the reader has asked for. Released on returning to the end. */
1106
+ const transcriptWindowFloorRef = useRef(Number.POSITIVE_INFINITY);
1107
+ /**
1108
+ * The row to hold still while history is mounted above it.
1109
+ *
1110
+ * Rows appearing above the viewport would otherwise push the reader's place
1111
+ * down the screen. `contentOffset` is where the row sat before the mount, so
1112
+ * the restore can tell a laid-out frame from one that still shows the old
1113
+ * tree, and `viewportOffset` is the screen position to put it back at.
1114
+ */
1115
+ const transcriptWindowAnchorRef = useRef<
1116
+ { index: number; viewportOffset: number; contentOffset: number; frames: number } | null
1117
+ >(null);
1118
+ /**
1119
+ * Did the reader just finish dragging the transcript somewhere?
1120
+ *
1121
+ * Only a drag raises this: every scroll the app makes is a property
1122
+ * assignment, which raises no mouse event, and a wheel raises a different
1123
+ * event. The window needs the difference. A drag that ends against the top
1124
+ * of the mounted rows is a reader asking for the history above them, while a
1125
+ * reveal that lands a row in the same place is asking for nothing.
1126
+ */
1127
+ /** Reveals still waiting for React to draw the row they asked for. */
1128
+ const transcriptRevealsRef = useRef(0);
1129
+ const transcriptReaderDragRef = useRef(false);
1130
+ const onTranscriptReaderDrag = useRef(() => {
1131
+ transcriptReaderDragRef.current = true;
1132
+ }).current;
1133
+ const transcriptWindowRowCount = transcriptWindowRows(height);
1134
+ const transcriptWindowRowsRef = useRef(transcriptWindowRowCount);
1135
+ transcriptWindowRowsRef.current = transcriptWindowRowCount;
1136
+ // Another agent's transcript is another conversation, shown from its end. Its
1137
+ // scrollbox is a new one, so none of the positions collected for the previous
1138
+ // view mean anything against it.
1139
+ const transcriptWindowAgentRef = useRef(activeAgentId);
1140
+ if (transcriptWindowAgentRef.current !== activeAgentId) {
1141
+ transcriptWindowAgentRef.current = activeAgentId;
1142
+ transcriptAtBottomRef.current = true;
1143
+ transcriptWindowFloorRef.current = Number.POSITIVE_INFINITY;
1144
+ transcriptWindowAnchorRef.current = null;
1145
+ }
1146
+ const transcriptWindowStart = clampWindowStart(
1147
+ Math.min(
1148
+ transcriptAtBottomRef.current
1149
+ ? tailWindowStart(visibleLines.length, transcriptWindowRowCount)
1150
+ : transcriptWindowStartRef.current,
1151
+ transcriptWindowFloorRef.current,
1152
+ ),
1153
+ visibleLines.length,
1154
+ );
1155
+ transcriptWindowStartRef.current = transcriptWindowStart;
1156
+ /** Bumped to re-render when the reader changes the window from a callback. */
1157
+ const [, setTranscriptWindowTick] = useState(0);
965
1158
  useLayoutEffect(() => {
966
1159
  const next = Math.max(0, Math.min(transcriptCursorRef.current, visibleLines.length - 1));
967
1160
  transcriptCursorRef.current = next;
@@ -1006,11 +1199,14 @@ export function App({
1006
1199
  1,
1007
1200
  width - 2 - promptRightColumns - visibleInputHint.length,
1008
1201
  );
1009
- // Slash commands only exist for the main agent, so a subagent view neither
1010
- // shows them nor completes them into a message to the child.
1011
- const commandSuggestions = shellMode || activeAgentId || stashOpen || commandSuggestionsDismissed
1202
+ // Most slash commands belong to main. A selected mutable agent can still own
1203
+ // a descendant started with /background, so expose only that command there.
1204
+ const commandSuggestions = shellMode || stashOpen || commandSuggestionsDismissed
1012
1205
  ? []
1013
- : matchingCommands(commandInput).slice(0, 5);
1206
+ : matchingCommandsForTarget(
1207
+ commandInput,
1208
+ activeAgentId ? "subagent" : "main",
1209
+ ).slice(0, 5);
1014
1210
  const pathSuggestions = (!shellMode && activeAgentId) || stashOpen || commandSuggestionsDismissed
1015
1211
  || isCommandInput(commandInput)
1016
1212
  || !shouldAutoShowPathCompletions(commandInput, inputCursorOffset)
@@ -1996,6 +2192,96 @@ export function App({
1996
2192
  };
1997
2193
  }, [renderer]);
1998
2194
 
2195
+ // Drive the mounted window off the scroll position. The frame is the only
2196
+ // place both the position and the laid-out rows are known, and nothing
2197
+ // renders while the app is idle, so this costs nothing then. Everything it
2198
+ // reads is a ref, so the handler installed on mount stays correct.
2199
+ useEffect(() => {
2200
+ const onFrame = () => {
2201
+ const scroll = transcriptScrollRef.current;
2202
+ if (!scroll || scroll.isDestroyed) return;
2203
+ const viewportHeight = scroll.viewport.height;
2204
+
2205
+ // A drag that ends against the top of the mounted rows asks for the
2206
+ // history above them. One window per gesture never reaches the start of a
2207
+ // long session, and every step holds the reader’s place, so the view
2208
+ // does not move and the first message stays out of reach. Mount the rest
2209
+ // instead, and leave the reader on it. This runs before the correction
2210
+ // below and drops it: the place to hold is the place they just left.
2211
+ if (!atWindowTop(scroll.scrollTop)) transcriptReaderDragRef.current = false;
2212
+ else if (transcriptReaderDragRef.current && transcriptWindowStartRef.current > 0) {
2213
+ transcriptReaderDragRef.current = false;
2214
+ transcriptWindowAnchorRef.current = null;
2215
+ ensureTranscriptRowMounted(0);
2216
+ return;
2217
+ }
2218
+
2219
+ // Put the reader's row back under the rows that just mounted above it.
2220
+ const anchor = transcriptWindowAnchorRef.current;
2221
+ if (anchor) {
2222
+ const row = scroll.findDescendantById(`transcript-line-${anchor.index}`);
2223
+ const contentOffset = row ? row.y - scroll.content.y : anchor.contentOffset;
2224
+ if (row && contentOffset !== anchor.contentOffset) {
2225
+ const target = topAnchorScrollTop(
2226
+ contentOffset - anchor.viewportOffset,
2227
+ scroll.scrollHeight,
2228
+ viewportHeight,
2229
+ );
2230
+ // scrollBy, not scrollTop: it is the path that marks the scroll as
2231
+ // manual, and without that sticky-to-bottom drags the reader back to
2232
+ // the end of a transcript they are reading the middle of.
2233
+ if (target !== scroll.scrollTop) scroll.scrollBy({ x: 0, y: target - scroll.scrollTop });
2234
+ transcriptWindowAnchorRef.current = null;
2235
+ } else if (anchor.frames++ > ANCHOR_FRAME_BUDGET) {
2236
+ // The rows never arrived. Drop the anchor rather than hold a scroll
2237
+ // correction that would fire against some later, unrelated layout.
2238
+ transcriptWindowAnchorRef.current = null;
2239
+ }
2240
+ }
2241
+
2242
+ const atBottom = atWindowBottom(scroll.scrollTop, scroll.scrollHeight, viewportHeight);
2243
+ transcriptAtBottomRef.current = atBottom;
2244
+ if (atBottom) {
2245
+ // A reveal that is still waiting for its row holds the window. The view
2246
+ // does not leave the end until it scrolls, so releasing here would
2247
+ // unmount the very row it just asked for, every frame until it gave up.
2248
+ if (transcriptRevealsRef.current > 0) return;
2249
+ // Back at the end: the window may shrink to its tail again. Releasing
2250
+ // the floor only changes a ref, so the render that acts on it has to be
2251
+ // asked for. Once per return to the end, never on every frame.
2252
+ if (transcriptWindowFloorRef.current !== Number.POSITIVE_INFINITY) {
2253
+ transcriptWindowFloorRef.current = Number.POSITIVE_INFINITY;
2254
+ setTranscriptWindowTick((tick) => tick + 1);
2255
+ }
2256
+ return;
2257
+ }
2258
+ if (transcriptWindowStartRef.current <= 0) return;
2259
+ if (!nearWindowTop(scroll.scrollTop, viewportHeight)) return;
2260
+ // One mount at a time. The anchor clears once the rows are laid out.
2261
+ if (transcriptWindowAnchorRef.current) return;
2262
+
2263
+ const current = transcriptWindowStartRef.current;
2264
+ const next = extendedWindowStart(current, transcriptWindowRowsRef.current);
2265
+ if (next === current) return;
2266
+ const row = scroll.findDescendantById(`transcript-line-${current}`);
2267
+ transcriptWindowAnchorRef.current = row
2268
+ ? {
2269
+ index: current,
2270
+ viewportOffset: row.y - scroll.viewport.y,
2271
+ contentOffset: row.y - scroll.content.y,
2272
+ frames: 0,
2273
+ }
2274
+ : null;
2275
+ transcriptWindowStartRef.current = next;
2276
+ transcriptWindowFloorRef.current = Math.min(transcriptWindowFloorRef.current, next);
2277
+ setTranscriptWindowTick((tick) => tick + 1);
2278
+ };
2279
+ renderer.on("frame", onFrame);
2280
+ return () => {
2281
+ renderer.off("frame", onFrame);
2282
+ };
2283
+ }, [renderer]);
2284
+
1999
2285
  // Hosted web searches are not pi tool calls, so they arrive out of band.
2000
2286
  useEffect(() => {
2001
2287
  return observeSearchCalls(session.sessionId, (call) => {
@@ -2754,19 +3040,36 @@ export function App({
2754
3040
  }
2755
3041
  }
2756
3042
  }
2757
- if (targetIndex < 0) return;
3043
+ const targetLine = lines[targetIndex];
3044
+ if (!targetLine) {
3045
+ // A stored answer whose text no longer appears in the session, after a
3046
+ // compaction for example, has no row to jump to, and neither has the
3047
+ // prompt that asked for it. Say so: leaving the popup open makes the key
3048
+ // look broken.
3049
+ newsOpenRef.current = false;
3050
+ setNewsOpen(false);
3051
+ append({
3052
+ kind: "text",
3053
+ role: "error",
3054
+ text: "news: that message is not in the transcript any more",
3055
+ });
3056
+ queueMicrotask(() => inputRef.current?.focus());
3057
+ return;
3058
+ }
2758
3059
 
2759
3060
  if (activeAgentIdRef.current !== requesterAgentId && !selectAgentView(requesterAgentId)) return;
2760
3061
  newsOpenRef.current = false;
2761
3062
  setNewsOpen(false);
2762
- const scrollToTarget = () => {
2763
- const transcript = transcriptScrollRef.current;
2764
- if (!transcript) return;
2765
- transcript.scrollTop = 0;
2766
- transcript.scrollChildIntoView(`transcript-line-${targetIndex}`);
2767
- };
2768
- queueMicrotask(scrollToTarget);
2769
- setTimeout(scrollToTarget, 30);
3063
+ // Rows are the projected lines: successful tool calls fold into one
3064
+ // activity row and hidden kinds drop out, so the line has to be matched to
3065
+ // the row that draws it. The match runs at scroll time, which is also
3066
+ // after a switch to another agent has drawn that agent’s rows.
3067
+ // The answer can be anywhere in the session, including far above the rows
3068
+ // that are mounted, so this asks for the row and waits for it.
3069
+ scrollToTranscriptRow(
3070
+ () => visibleLinesRef.current.indexOf(targetLine as MinimalTranscriptLine),
3071
+ { fromTop: true },
3072
+ );
2770
3073
  };
2771
3074
 
2772
3075
  const toggleCurrentNewsRead = () => {
@@ -3551,6 +3854,99 @@ export function App({
3551
3854
  return true;
3552
3855
  };
3553
3856
 
3857
+ const appendRequesterLine = (requesterAgentId: string | null, line: Line) => {
3858
+ if (requesterAgentId === null || !subagentManager.getAgent(requesterAgentId)) {
3859
+ appendMainLine(line);
3860
+ } else {
3861
+ subagentManager.appendAgentLine(requesterAgentId, line);
3862
+ }
3863
+ };
3864
+
3865
+ /** Start a fresh managed child without occupying or steering the requester. */
3866
+ const runBackgroundCommand = (
3867
+ text: string,
3868
+ requesterAgentId: string | null,
3869
+ draftText = text,
3870
+ ): boolean => {
3871
+ const command = parseBackgroundCommand(text);
3872
+ if (!command) return false;
3873
+ const requesterKey = requesterAgentId ?? "main";
3874
+ const restoreDraft = () => {
3875
+ if (activeAgentIdRef.current !== requesterAgentId) {
3876
+ if (!(viewDrafts.current.get(requesterKey) ?? "")) {
3877
+ viewDrafts.current.set(requesterKey, draftText);
3878
+ }
3879
+ return;
3880
+ }
3881
+ if (!(inputRef.current?.plainText ?? "")) {
3882
+ viewDrafts.current.set(requesterKey, draftText);
3883
+ setEditorText(draftText, draftText.length, true);
3884
+ }
3885
+ };
3886
+ if (command.kind === "error") {
3887
+ setEditorText(draftText, draftText.length, true);
3888
+ appendRequesterLine(requesterAgentId, {
3889
+ kind: "text",
3890
+ role: "error",
3891
+ text: command.message,
3892
+ });
3893
+ return true;
3894
+ }
3895
+ if (sessionSwitchRef.current) {
3896
+ setEditorText(draftText, draftText.length, true);
3897
+ appendRequesterLine(requesterAgentId, {
3898
+ kind: "text",
3899
+ role: "error",
3900
+ text: "wait for the session change to finish before starting a background agent",
3901
+ });
3902
+ return true;
3903
+ }
3904
+ if (relocatingRef.current || pendingRelocationRef.current) {
3905
+ setEditorText(draftText, draftText.length, true);
3906
+ appendRequesterLine(requesterAgentId, {
3907
+ kind: "text",
3908
+ role: "error",
3909
+ text: "wait for the worktree move to finish before starting a background agent",
3910
+ });
3911
+ return true;
3912
+ }
3913
+
3914
+ setEditingStash(null);
3915
+ viewDrafts.current.set(requesterKey, "");
3916
+ setEditorText("");
3917
+ histCursor.current = null;
3918
+ draft.current = "";
3919
+ void (async () => {
3920
+ // The registry and every child session belong to the active main session.
3921
+ // Bind it before spawning so an App-start race cannot persist elsewhere.
3922
+ await subagentManager.bindMainSession(session.sessionManager, cwd);
3923
+ const spawned = requesterAgentId === null
3924
+ ? await subagentManager.spawnBackground({
3925
+ task: command.prompt,
3926
+ requesterAgentId: null,
3927
+ modelId: `${session.agent.state.model.provider}/${session.agent.state.model.id}`,
3928
+ thinkingLevel: String(session.agent.state.thinkingLevel),
3929
+ })
3930
+ : await subagentManager.spawnBackground({
3931
+ task: command.prompt,
3932
+ requesterAgentId,
3933
+ });
3934
+ appendRequesterLine(requesterAgentId, {
3935
+ kind: "text",
3936
+ role: "system",
3937
+ text: `background agent started: ${spawned.name} (${spawned.id})\n${spawned.worktree.branch}\n${spawned.worktree.path}`,
3938
+ });
3939
+ })().catch((error) => {
3940
+ appendRequesterLine(requesterAgentId, {
3941
+ kind: "text",
3942
+ role: "error",
3943
+ text: `background agent could not start: ${String(error)}`,
3944
+ });
3945
+ restoreDraft();
3946
+ });
3947
+ return true;
3948
+ };
3949
+
3554
3950
  const submitPrompt = (value?: string, stashIndex?: number) => {
3555
3951
  // Read the selected agent from the ref, not the state. A view switch updates
3556
3952
  // the ref synchronously, but a switch-then-send in one input chunk runs
@@ -3578,6 +3974,26 @@ export function App({
3578
3974
 
3579
3975
  if (!promptText && attachments.length === 0 && pastedTexts.length === 0) return;
3580
3976
 
3977
+ const backgroundCandidate = commandEligible ? parseBackgroundCommand(promptText) : null;
3978
+ if (backgroundCandidate?.kind === "error") {
3979
+ setEditorText(rawDisplayText, rawDisplayText.length, true);
3980
+ appendRequesterLine(selectedAgentId, {
3981
+ kind: "text",
3982
+ role: "error",
3983
+ text: backgroundCandidate.message,
3984
+ });
3985
+ return;
3986
+ }
3987
+ if (backgroundCandidate && (attachments.length > 0 || pastedTexts.length > 0)) {
3988
+ setEditorText(rawDisplayText, rawDisplayText.length, true);
3989
+ appendRequesterLine(selectedAgentId, {
3990
+ kind: "text",
3991
+ role: "error",
3992
+ text: "/background accepts text only; remove image and pasted-text attachments",
3993
+ });
3994
+ return;
3995
+ }
3996
+
3581
3997
  // The main session is being replaced, so keep the draft rather than deliver
3582
3998
  // into a session that is about to be aborted and disposed.
3583
3999
  if (!selectedAgentId && sessionSwitchRef.current) {
@@ -3659,6 +4075,17 @@ export function App({
3659
4075
  return;
3660
4076
  }
3661
4077
 
4078
+ // /background belongs to the selected transcript, unlike the main-only
4079
+ // command router below, and must never become an ordinary child message.
4080
+ if (
4081
+ attachments.length === 0
4082
+ && commandEligible
4083
+ && runBackgroundCommand(promptText, selectedAgentId, rawDisplayText)
4084
+ ) {
4085
+ if (!selectedAgentId) appendCommandHistory();
4086
+ return;
4087
+ }
4088
+
3662
4089
  // AFK is process-global, so it is intercepted above the child routing below.
3663
4090
  // Successful command handling still makes the entered command recallable.
3664
4091
  if (attachments.length === 0 && commandEligible && runAfkCommand(promptText)) {
@@ -4053,8 +4480,65 @@ export function App({
4053
4480
  } else queueMicrotask(() => inputRef.current?.focus());
4054
4481
  };
4055
4482
 
4483
+ /**
4484
+ * Put a row in the tree so it can be scrolled to.
4485
+ *
4486
+ * Only rows near the end are mounted, so anything that scrolls to a row has
4487
+ * to ask for it first. The floor keeps it mounted: without one, the very next
4488
+ * frame could decide the reader is still at the end and drop it again before
4489
+ * React had rendered it.
4490
+ */
4491
+ const ensureTranscriptRowMounted = (index: number) => {
4492
+ const next = windowStartForRow(transcriptWindowStartRef.current, index);
4493
+ if (next >= transcriptWindowStartRef.current) return;
4494
+ transcriptWindowStartRef.current = next;
4495
+ transcriptWindowFloorRef.current = Math.min(transcriptWindowFloorRef.current, next);
4496
+ setTranscriptWindowTick((tick) => tick + 1);
4497
+ };
4498
+
4499
+ /**
4500
+ * Scroll to a row once React has drawn it.
4501
+ *
4502
+ * A row that had to be mounted first is not in the tree at microtask time,
4503
+ * and a long transcript can take several frames to draw it, so keep asking
4504
+ * rather than asking once. `wanted` stops a walk that has moved on: the
4505
+ * reader holding a key starts one of these per row, and the older ones must
4506
+ * not drag the view back to where the walk began.
4507
+ */
4508
+ const scrollToTranscriptRow = (
4509
+ resolve: () => number,
4510
+ options: { fromTop?: boolean; wanted?: () => boolean } = {},
4511
+ ) => {
4512
+ transcriptRevealsRef.current++;
4513
+ let tries = 0;
4514
+ const done = () => {
4515
+ transcriptRevealsRef.current = Math.max(0, transcriptRevealsRef.current - 1);
4516
+ };
4517
+ const reveal = () => {
4518
+ if (options.wanted && !options.wanted()) return done();
4519
+ const index = resolve();
4520
+ const scroll = transcriptScrollRef.current;
4521
+ if (scroll && index >= 0) {
4522
+ // Asked for again on every try: the row is only held by the window
4523
+ // while something wants it, and the frame that runs in between is free
4524
+ // to decide the reader is at the end of the transcript.
4525
+ ensureTranscriptRowMounted(index);
4526
+ if (scroll.findDescendantById(`transcript-line-${index}`)) {
4527
+ // From the top, the row lands on the first screen row instead of the
4528
+ // last: `scrollChildIntoView` moves as little as it can.
4529
+ if (options.fromTop) scroll.scrollTop = 0;
4530
+ scroll.scrollChildIntoView(`transcript-line-${index}`);
4531
+ return done();
4532
+ }
4533
+ }
4534
+ if (tries++ < ROW_DRAW_TRIES) setTimeout(reveal, ROW_DRAW_RETRY_MS);
4535
+ else done();
4536
+ };
4537
+ queueMicrotask(reveal);
4538
+ };
4539
+
4056
4540
  const revealTranscriptCursor = (index: number) => {
4057
- queueMicrotask(() => transcriptScrollRef.current?.scrollChildIntoView(`transcript-line-${index}`));
4541
+ scrollToTranscriptRow(() => index, { wanted: () => transcriptCursorRef.current === index });
4058
4542
  };
4059
4543
 
4060
4544
  /**
@@ -4066,6 +4550,7 @@ export function App({
4066
4550
  * The layout runs after React commits, hence the second, later attempt.
4067
4551
  */
4068
4552
  const anchorTranscriptRow = (index: number) => {
4553
+ ensureTranscriptRowMounted(index);
4069
4554
  const apply = () => {
4070
4555
  const scroll = transcriptScrollRef.current;
4071
4556
  const row = scroll?.findDescendantById(`transcript-line-${index}`);
@@ -4115,6 +4600,9 @@ export function App({
4115
4600
  selectTranscriptRow(index);
4116
4601
  toggleTranscriptDetail(index);
4117
4602
  };
4603
+ // Rows are memoized, so the handler they receive has to keep one identity for
4604
+ // the life of the app. The ref carries the current closure behind it.
4605
+ clickTranscriptDisclosureRef.current = clickTranscriptDisclosure;
4118
4606
 
4119
4607
  const copyTranscriptRow = () => {
4120
4608
  const line = visibleLines[transcriptCursorRef.current];
@@ -4626,9 +5114,12 @@ export function App({
4626
5114
  isReturnKey && !hasCtrlForReturn && !hasShiftForReturn && !hasAltForReturn;
4627
5115
  const inputValue = inputRef.current?.plainText ?? "";
4628
5116
  const commandMatches =
4629
- shellModeRef.current || activeAgentIdRef.current || stashOpenRef.current || commandSuggestionsDismissedRef.current
5117
+ shellModeRef.current || stashOpenRef.current || commandSuggestionsDismissedRef.current
4630
5118
  ? []
4631
- : matchingCommands(inputValue).slice(0, 5);
5119
+ : matchingCommandsForTarget(
5120
+ inputValue,
5121
+ activeAgentIdRef.current ? "subagent" : "main",
5122
+ ).slice(0, 5);
4632
5123
  const inputCursor = inputRef.current?.cursorOffset ?? inputValue.length;
4633
5124
  const pathMatches =
4634
5125
  (!shellModeRef.current && activeAgentIdRef.current)
@@ -4756,7 +5247,7 @@ export function App({
4756
5247
  queueMicrotask(() => inputRef.current?.focus());
4757
5248
  return;
4758
5249
  }
4759
- if (!activeAgentId && commandMatches.length > 0 && !/\s/.test(inputValue)) {
5250
+ if (commandMatches.length > 0 && !/\s/.test(inputValue)) {
4760
5251
  key.stopPropagation();
4761
5252
  const selected = commandMatches[Math.min(commandCursorRef.current, commandMatches.length - 1)]!;
4762
5253
  setEditorText(selected.name);
@@ -5002,6 +5493,59 @@ export function App({
5002
5493
  ? needsTranscriptGap(lastLine, { kind: "text", role: visibleTx.stream.kind, text: visibleTx.stream.text })
5003
5494
  : false;
5004
5495
 
5496
+ // One element for the whole transcript, rebuilt only when what it shows
5497
+ // changes. React walks a child list of this size on every render of the app,
5498
+ // and an answer arriving mid-turn re-renders the app many times a second, so
5499
+ // handing back the identical element lets React skip the list entirely.
5500
+ // Every value a row reads is a dependency below. Add the dependency when a
5501
+ // row starts reading something new, or the rows go stale.
5502
+ // The stream is reduced to a flag on purpose: only the caret on the last row
5503
+ // depends on it, so a delta must not rebuild the settled rows.
5504
+ const streaming = visibleTx.stream !== null;
5505
+ const transcriptRows = useMemo(
5506
+ () => <>{visibleLines.slice(transcriptWindowStart).map((line, offset) => {
5507
+ // The absolute index, not the offset in the window: the row ids, the
5508
+ // transcript cursor, and the gap rule are all in terms of the whole
5509
+ // transcript, and they must not change when older rows mount.
5510
+ const i = transcriptWindowStart + offset;
5511
+ const projectedKey = projectedLineKey(line, i);
5512
+ return (
5513
+ <TranscriptRow
5514
+ key={projectedKey}
5515
+ theme={theme}
5516
+ syntaxStyle={syntaxStyle}
5517
+ line={line}
5518
+ index={i}
5519
+ selected={transcriptFocused && transcriptCursor === i}
5520
+ expanded={detailOverrides.get(projectedKey) ?? outputMode === "verbose"}
5521
+ outputMode={outputMode}
5522
+ workingCaret={visibleBusy && !streaming && i === visibleLines.length - 1}
5523
+ gapBefore={needsTranscriptGap(visibleLines[i - 1], line)}
5524
+ news={
5525
+ line.kind === "text" && line.role === "assistant" && line.newsId
5526
+ ? (newsReadById.get(line.newsId) ? "seen" : "unseen")
5527
+ : undefined
5528
+ }
5529
+ onDisclosure={onTranscriptDisclosure}
5530
+ />
5531
+ );
5532
+ })}</>,
5533
+ [
5534
+ visibleLines,
5535
+ transcriptWindowStart,
5536
+ theme,
5537
+ syntaxStyle,
5538
+ outputMode,
5539
+ detailOverrides,
5540
+ transcriptFocused,
5541
+ transcriptCursor,
5542
+ visibleBusy,
5543
+ streaming,
5544
+ newsReadById,
5545
+ onTranscriptDisclosure,
5546
+ ],
5547
+ );
5548
+
5005
5549
  return (
5006
5550
  <AnimationProvider
5007
5551
  enabled={animations}
@@ -5049,69 +5593,12 @@ export function App({
5049
5593
  style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}
5050
5594
  stickyScroll
5051
5595
  stickyStart="bottom"
5596
+ onMouseDragEnd={onTranscriptReaderDrag}
5597
+ onMouseDrop={onTranscriptReaderDrag}
5052
5598
  verticalScrollbarOptions={{ visible: true }}
5053
5599
  >
5054
5600
  <RenderErrorBoundary theme={theme} label="transcript" resetKey={transcriptResetKey}>
5055
- {visibleLines.map((line, i) => {
5056
- const workingCaret = visibleBusy && !visibleTx.stream && i === visibleLines.length - 1;
5057
- const projectedKey = projectedLineKey(line, i);
5058
- const selected = transcriptFocused && transcriptCursor === i;
5059
- const expanded = detailOverrides.get(projectedKey) ?? outputMode === "verbose";
5060
- const row =
5061
- line.kind === "tool-summary" ? (
5062
- <ActivitySummaryLine
5063
- theme={theme}
5064
- syntaxStyle={syntaxStyle}
5065
- summary={line}
5066
- expanded={expanded}
5067
- outputMode={outputMode}
5068
- onDisclosureClick={() => clickTranscriptDisclosure(i)}
5069
- />
5070
- ) : line.kind === "tool" ? (
5071
- <ToolLine
5072
- theme={theme}
5073
- syntaxStyle={syntaxStyle}
5074
- call={line.call}
5075
- workingCaret={workingCaret}
5076
- outputMode={outputMode}
5077
- expanded={expanded}
5078
- onDisclosureClick={() => clickTranscriptDisclosure(i)}
5079
- />
5080
- ) : line.kind === "agent-message" ? (
5081
- <AgentMessageLine theme={theme} syntaxStyle={syntaxStyle} line={line} />
5082
- ) : line.kind === "goal-review" ? (
5083
- <GoalReviewLine theme={theme} line={line} />
5084
- ) : (
5085
- <TextLine
5086
- theme={theme}
5087
- syntaxStyle={syntaxStyle}
5088
- role={line.role as Role}
5089
- text={line.text}
5090
- workingCaret={workingCaret}
5091
- news={
5092
- line.kind === "text" && line.role === "assistant" && line.newsId
5093
- ? (newsReadById.get(line.newsId) ? "seen" : "unseen")
5094
- : undefined
5095
- }
5096
- />
5097
- );
5098
- const gapBefore = needsTranscriptGap(visibleLines[i - 1], line);
5099
- return (
5100
- <box
5101
- id={`transcript-line-${i}`}
5102
- key={projectedKey}
5103
- style={{
5104
- flexDirection: "column",
5105
- width: "100%",
5106
- flexShrink: 0,
5107
- backgroundColor: selected ? theme.selectionBg : "transparent",
5108
- }}
5109
- >
5110
- {gapBefore ? <Gap /> : null}
5111
- {row}
5112
- </box>
5113
- );
5114
- })}
5601
+ {transcriptRows}
5115
5602
  {visibleTx.stream ? (
5116
5603
  <>
5117
5604
  {/* Same gap while the answer is still arriving, so it does not
@@ -0,0 +1,24 @@
1
+ export type BackgroundCommand =
2
+ | { kind: "spawn"; prompt: string }
3
+ | { kind: "error"; message: string };
4
+
5
+ export const BACKGROUND_USAGE = "Usage: /background <prompt>";
6
+
7
+ /** True for any input `/background` owns, so App can route it before child prompts. */
8
+ export function isBackgroundCommand(text: string): boolean {
9
+ return /^\/background(?:\s|$)/.test(text.trim());
10
+ }
11
+
12
+ /** Parse one fresh managed-agent task without interpreting aliases or control words. */
13
+ export function parseBackgroundCommand(text: string): BackgroundCommand | null {
14
+ const trimmed = text.trim();
15
+ if (!isBackgroundCommand(trimmed)) return null;
16
+ const prompt = trimmed.slice("/background".length).trim();
17
+ if (!prompt) {
18
+ return {
19
+ kind: "error",
20
+ message: `/background needs a prompt. ${BACKGROUND_USAGE}`,
21
+ };
22
+ }
23
+ return { kind: "spawn", prompt };
24
+ }
package/src/commands.ts CHANGED
@@ -29,6 +29,10 @@ export const COMMANDS: Command[] = [
29
29
  name: "/afk",
30
30
  description: "Toggle away mode, or start it with instructions",
31
31
  },
32
+ {
33
+ name: "/background",
34
+ description: "Start a managed worktree agent for the selected transcript",
35
+ },
32
36
  {
33
37
  name: "/history",
34
38
  description: "Browse saved sessions for this directory",
@@ -73,15 +77,24 @@ export function isCommandInput(input: string): boolean {
73
77
  }
74
78
 
75
79
  export function matchingCommands(input: string): Command[] {
76
- if (!isCommandInput(input) || input.includes("\n")) return [];
77
- const name = input.split(/\s/, 1)[0]!;
80
+ // Suggestions complete only the command name. Once an argument starts, the
81
+ // prompt belongs to the editor and Up/Down must navigate wrapped input.
82
+ if (!isCommandInput(input) || /\s/.test(input)) return [];
78
83
  // No command name holds a second separator, so one means the user is typing
79
84
  // an absolute path. Leaving it to prefix matching would let Tab on /u turn
80
85
  // /usr/lib into /new.
81
- if (/[/\\]/.test(name.slice(1))) return [];
82
- return COMMANDS.filter((command) =>
83
- /\s/.test(input) ? command.name === name : command.name.startsWith(input),
84
- );
86
+ if (/[/\\]/.test(input.slice(1))) return [];
87
+ return COMMANDS.filter((command) => command.name.startsWith(input));
88
+ }
89
+
90
+ export function matchingCommandsForTarget(
91
+ input: string,
92
+ target: "main" | "subagent",
93
+ ): Command[] {
94
+ const matches = matchingCommands(input);
95
+ return target === "subagent"
96
+ ? matches.filter((command) => command.name === "/background")
97
+ : matches;
85
98
  }
86
99
 
87
100
  export function moveCommandSelection(current: number, count: number, step: -1 | 1): number {
@@ -59,6 +59,7 @@ export const HELP_GROUPS: HelpGroup[] = [
59
59
  ["/clear", "Start a fresh session"],
60
60
  ["/goal", "Set or control a goal"],
61
61
  ["/goalf", "Work out a goal, then start it"],
62
+ ["/background", "Start a managed agent for the selected transcript"],
62
63
  ["/history", "Browse saved sessions"],
63
64
  ["/login", "Add or update a provider"],
64
65
  ["/news", "Open recent answers (News)"],
@@ -284,6 +284,18 @@ type OpenReminderResources = {
284
284
 
285
285
  const TERMINAL_SUBAGENT_STATUSES: readonly SubagentStatus[] = ["completed", "failed", "stopped"];
286
286
 
287
+ export type BackgroundSpawnRequest =
288
+ | {
289
+ task: string;
290
+ requesterAgentId: null;
291
+ modelId: string;
292
+ thinkingLevel: string;
293
+ }
294
+ | {
295
+ task: string;
296
+ requesterAgentId: string;
297
+ };
298
+
287
299
  type ManagerOptions = {
288
300
  modelRuntime: ModelRuntime;
289
301
  agentDir: string;
@@ -488,7 +500,14 @@ export class SubagentManager {
488
500
  cwd: string,
489
501
  ): Promise<void> {
490
502
  const sessionId = sessionManager.getSessionId();
491
- if (this.mainApi === pi && this.parentSessionId === sessionId && this.mainSessionManager) return;
503
+ if (this.mainApi === pi && this.parentSessionId === sessionId && this.mainSessionManager) {
504
+ // A relocated session keeps its identity while its authoritative project
505
+ // root changes. Fresh worktrees must be based on the active directory,
506
+ // and direct UI commands must persist through the current manager object.
507
+ this.mainSessionManager = sessionManager;
508
+ this.mainCwd = cwd;
509
+ return;
510
+ }
492
511
  if (this.parentSessionId !== "detached") {
493
512
  this.spawnPreviewManager?.cancelRequester(this.parentSessionId);
494
513
  await this.shellManager?.invalidateSession(this.parentSessionId);
@@ -1770,6 +1789,49 @@ export class SubagentManager {
1770
1789
  return this.spawnPreviewManager.request(requester, options, signal);
1771
1790
  }
1772
1791
 
1792
+ /**
1793
+ * Start a user-requested managed agent with only its task as conversation
1794
+ * context. A selected mutable agent owns the descendant and supplies its
1795
+ * model settings; otherwise the main session owns it.
1796
+ */
1797
+ async spawnBackground(request: BackgroundSpawnRequest): Promise<SubagentSnapshot> {
1798
+ if (!request.task.trim()) throw new Error("A background agent needs a prompt");
1799
+ if (request.requesterAgentId === null) {
1800
+ return this.spawn({
1801
+ task: request.task,
1802
+ modelId: request.modelId,
1803
+ thinkingLevel: request.thinkingLevel,
1804
+ parentAgentId: null,
1805
+ context: "fresh",
1806
+ createWorktree: true,
1807
+ role: "worker",
1808
+ });
1809
+ }
1810
+
1811
+ const parent = this.records.get(request.requesterAgentId);
1812
+ if (!parent) throw new Error("Spawner subagent no longer exists");
1813
+ if (isInternalRole(parent.snapshot.role)) {
1814
+ throw new Error("Internal agents cannot own background agents");
1815
+ }
1816
+ if (parent.snapshot.readonly) {
1817
+ throw new Error("Readonly subagents cannot spawn child agents");
1818
+ }
1819
+ if (!["starting", "running", "idle"].includes(parent.snapshot.status)) {
1820
+ throw new Error(
1821
+ `Subagent ${parent.snapshot.name} cannot spawn while ${parent.snapshot.status}`,
1822
+ );
1823
+ }
1824
+ return this.spawn({
1825
+ task: request.task,
1826
+ modelId: parent.snapshot.modelId,
1827
+ thinkingLevel: parent.snapshot.thinkingLevel,
1828
+ parentAgentId: parent.snapshot.id,
1829
+ context: "fresh",
1830
+ createWorktree: true,
1831
+ role: "worker",
1832
+ });
1833
+ }
1834
+
1773
1835
  /** A descriptive record for an agent that runs in the launch project itself. */
1774
1836
  private projectWorktreeRecord(name: string): WorktreeRecord {
1775
1837
  const branch = readBranch(this.mainCwd) ?? "HEAD";
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Which part of the transcript is mounted.
3
+ *
4
+ * OpenTUI paints only what the viewport covers, but it lays out every mounted
5
+ * node on every frame, and it rebuilds the render list with them. A resumed
6
+ * session holds thousands of rows, so mounting all of them made the cost of one
7
+ * keystroke grow with the length of the conversation.
8
+ *
9
+ * The transcript therefore mounts a contiguous run that always reaches the last
10
+ * row: rows `[start, end)` of the projected lines. Older rows join the tree when
11
+ * the reader scrolls back to them, or when something asks to reveal one. They
12
+ * are never dropped while the reader is above the last row, so nothing can
13
+ * vanish from under a scroll position.
14
+ *
15
+ * All of this is index arithmetic on the projected lines. It is kept here, away
16
+ * from the renderer, so the rules can be read and tested on their own.
17
+ */
18
+
19
+ /** Rows added per step, and the smallest run kept mounted. */
20
+ export function transcriptWindowRows(terminalHeight: number): number {
21
+ // A row is one message, one tool call, or one summary, and it occupies at
22
+ // least one terminal row. Two terminal heights of rows therefore always
23
+ // outgrow the viewport, however short the individual rows turn out to be.
24
+ //
25
+ // That factor is what stops one step back turning into all of them: a step
26
+ // moves the reader more than one screen away from the top of the mounted run,
27
+ // so the trigger to mount more does not fire again straight away.
28
+ return Math.max(MIN_WINDOW_ROWS, Math.max(0, Math.floor(terminalHeight)) * 2);
29
+ }
30
+
31
+ /** Floor for a very short terminal, so scrolling back is not one row at a time. */
32
+ export const MIN_WINDOW_ROWS = 60;
33
+
34
+ /** Rows mounted before a revealed row, so it does not land against the top edge. */
35
+ export const REVEAL_MARGIN_ROWS = 20;
36
+
37
+ /** Keep a start inside the transcript. A shrunken transcript can strand one. */
38
+ export function clampWindowStart(start: number, lineCount: number): number {
39
+ const count = Math.max(0, lineCount);
40
+ if (!Number.isFinite(start)) return 0;
41
+ return Math.max(0, Math.min(Math.floor(start), count));
42
+ }
43
+
44
+ /**
45
+ * The start to use while the reader sits at the last row.
46
+ *
47
+ * Exactly one window of rows, so arriving rows cannot grow the mounted run
48
+ * without limit over a long turn, and a terminal that just got taller gets the
49
+ * rows to fill itself. History the reader asked for is held by the floor
50
+ * instead, which is released only on returning here.
51
+ */
52
+ export function tailWindowStart(lineCount: number, windowRows: number): number {
53
+ const rows = Math.max(1, Math.floor(windowRows));
54
+ return Math.max(0, Math.max(0, lineCount) - rows);
55
+ }
56
+
57
+ /** The start after the reader scrolls back for more history. */
58
+ export function extendedWindowStart(current: number, windowRows: number): number {
59
+ const rows = Math.max(1, Math.floor(windowRows));
60
+ return Math.max(0, clampWindowStart(current, Number.POSITIVE_INFINITY) - rows);
61
+ }
62
+
63
+ /**
64
+ * The start that puts `index` in the tree.
65
+ *
66
+ * Only ever moves the start backwards. A reveal must not unmount the rows the
67
+ * reader already has, and a target that is mounted already needs no change.
68
+ */
69
+ export function windowStartForRow(
70
+ current: number,
71
+ index: number,
72
+ margin = REVEAL_MARGIN_ROWS,
73
+ ): number {
74
+ if (!Number.isFinite(index) || index < 0) return current;
75
+ return Math.min(current, Math.max(0, Math.floor(index) - Math.max(0, margin)));
76
+ }
77
+
78
+ /**
79
+ * Is the viewport within one screen of the top of the mounted run?
80
+ *
81
+ * The trigger to mount more history. One screen of slack means the rows are
82
+ * there before the reader reaches the edge, rather than after it.
83
+ */
84
+ export function nearWindowTop(scrollTop: number, viewportHeight: number): boolean {
85
+ return scrollTop <= Math.max(1, viewportHeight);
86
+ }
87
+
88
+ /** Is the viewport at the last row? Sticky scroll holds it there while it is. */
89
+ export function atWindowBottom(
90
+ scrollTop: number,
91
+ scrollHeight: number,
92
+ viewportHeight: number,
93
+ ): boolean {
94
+ // One row of tolerance: the scroll position is rounded to whole rows, and a
95
+ // content height that just changed can leave it a row short of the end.
96
+ return scrollTop >= Math.max(0, scrollHeight - viewportHeight) - 1;
97
+ }
98
+
99
+ /**
100
+ * Is the viewport at the top of the mounted run?
101
+ *
102
+ * A reader who sends the view here has asked for what is above it. One window
103
+ * per gesture never reaches the start of a long session, so this answers a
104
+ * different question than `nearWindowTop` and gets a different response.
105
+ */
106
+ export function atWindowTop(scrollTop: number): boolean {
107
+ return scrollTop <= 0;
108
+ }