pum-agent 0.2.22-beta.1 → 0.2.23-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.23-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,12 @@ 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
+ /** How many of those tries it makes before it gives up. */
314
+ const ROW_DRAW_TRIES = 12;
315
+ /** Frames a scroll correction waits for its rows before it gives up. */
316
+ const ANCHOR_FRAME_BUDGET = 30;
294
317
  /** Keys that move around without changing the text. */
295
318
  const NAV_KEYS = new Set(["up", "down", "left", "right", "home", "end", "pageup", "pagedown"]);
296
319
 
@@ -391,6 +414,91 @@ export function promptPlaceholder(options: {
391
414
  /** A blank row. An empty <text> measures to nothing, so this needs a height. */
392
415
  const Gap = () => <box style={{ height: 1, flexShrink: 0 }} />;
393
416
 
417
+ /**
418
+ * One rendered transcript row.
419
+ *
420
+ * Memoized on purpose. The transcript is a child of the same component that
421
+ * holds the prompt draft, so without this every keystroke re-rendered every
422
+ * row, and the cost of a keypress grew with the length of the session. Each
423
+ * prop here must therefore stay identity-stable while the row is unchanged:
424
+ * pass the row index and one shared handler rather than a fresh closure.
425
+ */
426
+ const TranscriptRow = memo(function TranscriptRow({
427
+ theme,
428
+ syntaxStyle,
429
+ line,
430
+ index,
431
+ selected,
432
+ expanded,
433
+ outputMode,
434
+ workingCaret,
435
+ gapBefore,
436
+ news,
437
+ onDisclosure,
438
+ }: {
439
+ theme: Theme;
440
+ syntaxStyle: SyntaxStyle;
441
+ line: MinimalTranscriptLine;
442
+ index: number;
443
+ selected: boolean;
444
+ expanded: boolean;
445
+ outputMode: TranscriptOutputMode;
446
+ workingCaret: boolean;
447
+ gapBefore: boolean;
448
+ news?: "seen" | "unseen";
449
+ onDisclosure: (index: number) => void;
450
+ }) {
451
+ const onDisclosureClick = () => onDisclosure(index);
452
+ const row =
453
+ line.kind === "tool-summary" ? (
454
+ <ActivitySummaryLine
455
+ theme={theme}
456
+ syntaxStyle={syntaxStyle}
457
+ summary={line}
458
+ expanded={expanded}
459
+ outputMode={outputMode}
460
+ onDisclosureClick={onDisclosureClick}
461
+ />
462
+ ) : line.kind === "tool" ? (
463
+ <ToolLine
464
+ theme={theme}
465
+ syntaxStyle={syntaxStyle}
466
+ call={line.call}
467
+ workingCaret={workingCaret}
468
+ outputMode={outputMode}
469
+ expanded={expanded}
470
+ onDisclosureClick={onDisclosureClick}
471
+ />
472
+ ) : line.kind === "agent-message" ? (
473
+ <AgentMessageLine theme={theme} syntaxStyle={syntaxStyle} line={line} />
474
+ ) : line.kind === "goal-review" ? (
475
+ <GoalReviewLine theme={theme} line={line} />
476
+ ) : (
477
+ <TextLine
478
+ theme={theme}
479
+ syntaxStyle={syntaxStyle}
480
+ role={line.role as Role}
481
+ text={line.text}
482
+ workingCaret={workingCaret}
483
+ news={news}
484
+ />
485
+ );
486
+ return (
487
+ <box
488
+ id={`transcript-line-${index}`}
489
+ style={{
490
+ flexDirection: "column",
491
+ width: "100%",
492
+ flexShrink: 0,
493
+ backgroundColor: selected ? theme.selectionBg : "transparent",
494
+ }}
495
+ >
496
+ {gapBefore ? <Gap /> : null}
497
+ {row}
498
+ </box>
499
+ );
500
+ });
501
+
394
502
  type RenderErrorBoundaryProps = {
395
503
  theme: Theme;
396
504
  label: string;
@@ -808,6 +916,12 @@ export function App({
808
916
  const transcriptCursorRef = useRef(0);
809
917
  const [detailOverrides, setDetailOverrides] = useState<Map<string, boolean>>(() => new Map());
810
918
  const detailOverridesRef = useRef(detailOverrides);
919
+ // Disclosure clicks reach the memoized rows through one stable function, so a
920
+ // re-render of the app cannot invalidate every row by handing it a new one.
921
+ const clickTranscriptDisclosureRef = useRef<(index: number) => void>(() => {});
922
+ const onTranscriptDisclosure = useRef(
923
+ (index: number) => clickTranscriptDisclosureRef.current(index),
924
+ ).current;
811
925
  // Mirrors settings for update(): a keypress or an async .then can fire a
812
926
  // second update before React commits the first, so update() must build the
813
927
  // next value from the latest pending settings, not the render closure.
@@ -924,9 +1038,13 @@ export function App({
924
1038
  const todoVisible = todoOpen
925
1039
  && !settingsOpen && !helpOpen && !historyOpen && !statsOpen && !agentSelectorOpen
926
1040
  && !triggersOpen && !loginOpen && !newsOpen && !visibleQuestionnaire && !spawnPreview;
927
- const visibleTx = transcriptForThinkingVisibility(
928
- activeAgent?.transcript ?? tx,
929
- settings.showThinking,
1041
+ // Memoized because the filtered result is a new object every call whenever
1042
+ // the transcript holds reasoning. That new identity would re-run the dwell
1043
+ // and projection passes below on every render, keystrokes included.
1044
+ const sourceTx = activeAgent?.transcript ?? tx;
1045
+ const visibleTx = useMemo(
1046
+ () => transcriptForThinkingVisibility(sourceTx, settings.showThinking),
1047
+ [sourceTx, settings.showThinking],
930
1048
  );
931
1049
  const outputMode = transcriptOutputMode(settings);
932
1050
  const showAgentMessages = settings.showAgentMessages !== false;
@@ -958,10 +1076,79 @@ export function App({
958
1076
  () => projectTranscriptLines(held.lines, outputMode, showAgentMessages),
959
1077
  [held, outputMode, showAgentMessages],
960
1078
  );
1079
+ // The rows as they are drawn. A callback that has to find a row needs these,
1080
+ // not the transcript lines: folding and hidden kinds mean the two lists have
1081
+ // different lengths, so a line index is not a row index.
1082
+ const visibleLinesRef = useRef(visibleLines);
1083
+ visibleLinesRef.current = visibleLines;
961
1084
  const visiblePending = useMemo(
962
1085
  () => projectPendingTranscriptLines(visibleTx.pending, showAgentMessages),
963
1086
  [visibleTx.pending, showAgentMessages],
964
1087
  );
1088
+
1089
+ // Which rows are mounted. See `transcript-window.ts` for the rules; these
1090
+ // three refs are the state they run on.
1091
+ //
1092
+ // The start is derived during render rather than stored in state, so a
1093
+ // resumed session mounts its tail on the first render instead of mounting
1094
+ // everything and then trimming. Both derivations are idempotent, so a
1095
+ // repeated render cannot walk the window anywhere.
1096
+ const transcriptWindowStartRef = useRef(0);
1097
+ /** True while the last row is on screen. Only then may the window advance. */
1098
+ const transcriptAtBottomRef = useRef(true);
1099
+ /** Lowest start the reader has asked for. Released on returning to the end. */
1100
+ const transcriptWindowFloorRef = useRef(Number.POSITIVE_INFINITY);
1101
+ /**
1102
+ * The row to hold still while history is mounted above it.
1103
+ *
1104
+ * Rows appearing above the viewport would otherwise push the reader's place
1105
+ * down the screen. `contentOffset` is where the row sat before the mount, so
1106
+ * the restore can tell a laid-out frame from one that still shows the old
1107
+ * tree, and `viewportOffset` is the screen position to put it back at.
1108
+ */
1109
+ const transcriptWindowAnchorRef = useRef<
1110
+ { index: number; viewportOffset: number; contentOffset: number; frames: number } | null
1111
+ >(null);
1112
+ /**
1113
+ * Did the reader just finish dragging the transcript somewhere?
1114
+ *
1115
+ * Only a drag raises this: every scroll the app makes is a property
1116
+ * assignment, which raises no mouse event, and a wheel raises a different
1117
+ * event. The window needs the difference. A drag that ends against the top
1118
+ * of the mounted rows is a reader asking for the history above them, while a
1119
+ * reveal that lands a row in the same place is asking for nothing.
1120
+ */
1121
+ /** Reveals still waiting for React to draw the row they asked for. */
1122
+ const transcriptRevealsRef = useRef(0);
1123
+ const transcriptReaderDragRef = useRef(false);
1124
+ const onTranscriptReaderDrag = useRef(() => {
1125
+ transcriptReaderDragRef.current = true;
1126
+ }).current;
1127
+ const transcriptWindowRowCount = transcriptWindowRows(height);
1128
+ const transcriptWindowRowsRef = useRef(transcriptWindowRowCount);
1129
+ transcriptWindowRowsRef.current = transcriptWindowRowCount;
1130
+ // Another agent's transcript is another conversation, shown from its end. Its
1131
+ // scrollbox is a new one, so none of the positions collected for the previous
1132
+ // view mean anything against it.
1133
+ const transcriptWindowAgentRef = useRef(activeAgentId);
1134
+ if (transcriptWindowAgentRef.current !== activeAgentId) {
1135
+ transcriptWindowAgentRef.current = activeAgentId;
1136
+ transcriptAtBottomRef.current = true;
1137
+ transcriptWindowFloorRef.current = Number.POSITIVE_INFINITY;
1138
+ transcriptWindowAnchorRef.current = null;
1139
+ }
1140
+ const transcriptWindowStart = clampWindowStart(
1141
+ Math.min(
1142
+ transcriptAtBottomRef.current
1143
+ ? tailWindowStart(visibleLines.length, transcriptWindowRowCount)
1144
+ : transcriptWindowStartRef.current,
1145
+ transcriptWindowFloorRef.current,
1146
+ ),
1147
+ visibleLines.length,
1148
+ );
1149
+ transcriptWindowStartRef.current = transcriptWindowStart;
1150
+ /** Bumped to re-render when the reader changes the window from a callback. */
1151
+ const [, setTranscriptWindowTick] = useState(0);
965
1152
  useLayoutEffect(() => {
966
1153
  const next = Math.max(0, Math.min(transcriptCursorRef.current, visibleLines.length - 1));
967
1154
  transcriptCursorRef.current = next;
@@ -1006,11 +1193,14 @@ export function App({
1006
1193
  1,
1007
1194
  width - 2 - promptRightColumns - visibleInputHint.length,
1008
1195
  );
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
1196
+ // Most slash commands belong to main. A selected mutable agent can still own
1197
+ // a descendant started with /background, so expose only that command there.
1198
+ const commandSuggestions = shellMode || stashOpen || commandSuggestionsDismissed
1012
1199
  ? []
1013
- : matchingCommands(commandInput).slice(0, 5);
1200
+ : matchingCommandsForTarget(
1201
+ commandInput,
1202
+ activeAgentId ? "subagent" : "main",
1203
+ ).slice(0, 5);
1014
1204
  const pathSuggestions = (!shellMode && activeAgentId) || stashOpen || commandSuggestionsDismissed
1015
1205
  || isCommandInput(commandInput)
1016
1206
  || !shouldAutoShowPathCompletions(commandInput, inputCursorOffset)
@@ -1996,6 +2186,96 @@ export function App({
1996
2186
  };
1997
2187
  }, [renderer]);
1998
2188
 
2189
+ // Drive the mounted window off the scroll position. The frame is the only
2190
+ // place both the position and the laid-out rows are known, and nothing
2191
+ // renders while the app is idle, so this costs nothing then. Everything it
2192
+ // reads is a ref, so the handler installed on mount stays correct.
2193
+ useEffect(() => {
2194
+ const onFrame = () => {
2195
+ const scroll = transcriptScrollRef.current;
2196
+ if (!scroll || scroll.isDestroyed) return;
2197
+ const viewportHeight = scroll.viewport.height;
2198
+
2199
+ // A drag that ends against the top of the mounted rows asks for the
2200
+ // history above them. One window per gesture never reaches the start of a
2201
+ // long session, and every step holds the reader’s place, so the view
2202
+ // does not move and the first message stays out of reach. Mount the rest
2203
+ // instead, and leave the reader on it. This runs before the correction
2204
+ // below and drops it: the place to hold is the place they just left.
2205
+ if (!atWindowTop(scroll.scrollTop)) transcriptReaderDragRef.current = false;
2206
+ else if (transcriptReaderDragRef.current && transcriptWindowStartRef.current > 0) {
2207
+ transcriptReaderDragRef.current = false;
2208
+ transcriptWindowAnchorRef.current = null;
2209
+ ensureTranscriptRowMounted(0);
2210
+ return;
2211
+ }
2212
+
2213
+ // Put the reader's row back under the rows that just mounted above it.
2214
+ const anchor = transcriptWindowAnchorRef.current;
2215
+ if (anchor) {
2216
+ const row = scroll.findDescendantById(`transcript-line-${anchor.index}`);
2217
+ const contentOffset = row ? row.y - scroll.content.y : anchor.contentOffset;
2218
+ if (row && contentOffset !== anchor.contentOffset) {
2219
+ const target = topAnchorScrollTop(
2220
+ contentOffset - anchor.viewportOffset,
2221
+ scroll.scrollHeight,
2222
+ viewportHeight,
2223
+ );
2224
+ // scrollBy, not scrollTop: it is the path that marks the scroll as
2225
+ // manual, and without that sticky-to-bottom drags the reader back to
2226
+ // the end of a transcript they are reading the middle of.
2227
+ if (target !== scroll.scrollTop) scroll.scrollBy({ x: 0, y: target - scroll.scrollTop });
2228
+ transcriptWindowAnchorRef.current = null;
2229
+ } else if (anchor.frames++ > ANCHOR_FRAME_BUDGET) {
2230
+ // The rows never arrived. Drop the anchor rather than hold a scroll
2231
+ // correction that would fire against some later, unrelated layout.
2232
+ transcriptWindowAnchorRef.current = null;
2233
+ }
2234
+ }
2235
+
2236
+ const atBottom = atWindowBottom(scroll.scrollTop, scroll.scrollHeight, viewportHeight);
2237
+ transcriptAtBottomRef.current = atBottom;
2238
+ if (atBottom) {
2239
+ // A reveal that is still waiting for its row holds the window. The view
2240
+ // does not leave the end until it scrolls, so releasing here would
2241
+ // unmount the very row it just asked for, every frame until it gave up.
2242
+ if (transcriptRevealsRef.current > 0) return;
2243
+ // Back at the end: the window may shrink to its tail again. Releasing
2244
+ // the floor only changes a ref, so the render that acts on it has to be
2245
+ // asked for. Once per return to the end, never on every frame.
2246
+ if (transcriptWindowFloorRef.current !== Number.POSITIVE_INFINITY) {
2247
+ transcriptWindowFloorRef.current = Number.POSITIVE_INFINITY;
2248
+ setTranscriptWindowTick((tick) => tick + 1);
2249
+ }
2250
+ return;
2251
+ }
2252
+ if (transcriptWindowStartRef.current <= 0) return;
2253
+ if (!nearWindowTop(scroll.scrollTop, viewportHeight)) return;
2254
+ // One mount at a time. The anchor clears once the rows are laid out.
2255
+ if (transcriptWindowAnchorRef.current) return;
2256
+
2257
+ const current = transcriptWindowStartRef.current;
2258
+ const next = extendedWindowStart(current, transcriptWindowRowsRef.current);
2259
+ if (next === current) return;
2260
+ const row = scroll.findDescendantById(`transcript-line-${current}`);
2261
+ transcriptWindowAnchorRef.current = row
2262
+ ? {
2263
+ index: current,
2264
+ viewportOffset: row.y - scroll.viewport.y,
2265
+ contentOffset: row.y - scroll.content.y,
2266
+ frames: 0,
2267
+ }
2268
+ : null;
2269
+ transcriptWindowStartRef.current = next;
2270
+ transcriptWindowFloorRef.current = Math.min(transcriptWindowFloorRef.current, next);
2271
+ setTranscriptWindowTick((tick) => tick + 1);
2272
+ };
2273
+ renderer.on("frame", onFrame);
2274
+ return () => {
2275
+ renderer.off("frame", onFrame);
2276
+ };
2277
+ }, [renderer]);
2278
+
1999
2279
  // Hosted web searches are not pi tool calls, so they arrive out of band.
2000
2280
  useEffect(() => {
2001
2281
  return observeSearchCalls(session.sessionId, (call) => {
@@ -2754,19 +3034,36 @@ export function App({
2754
3034
  }
2755
3035
  }
2756
3036
  }
2757
- if (targetIndex < 0) return;
3037
+ const targetLine = lines[targetIndex];
3038
+ if (!targetLine) {
3039
+ // A stored answer whose text no longer appears in the session, after a
3040
+ // compaction for example, has no row to jump to, and neither has the
3041
+ // prompt that asked for it. Say so: leaving the popup open makes the key
3042
+ // look broken.
3043
+ newsOpenRef.current = false;
3044
+ setNewsOpen(false);
3045
+ append({
3046
+ kind: "text",
3047
+ role: "error",
3048
+ text: "news: that message is not in the transcript any more",
3049
+ });
3050
+ queueMicrotask(() => inputRef.current?.focus());
3051
+ return;
3052
+ }
2758
3053
 
2759
3054
  if (activeAgentIdRef.current !== requesterAgentId && !selectAgentView(requesterAgentId)) return;
2760
3055
  newsOpenRef.current = false;
2761
3056
  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);
3057
+ // Rows are the projected lines: successful tool calls fold into one
3058
+ // activity row and hidden kinds drop out, so the line has to be matched to
3059
+ // the row that draws it. The match runs at scroll time, which is also
3060
+ // after a switch to another agent has drawn that agent’s rows.
3061
+ // The answer can be anywhere in the session, including far above the rows
3062
+ // that are mounted, so this asks for the row and waits for it.
3063
+ scrollToTranscriptRow(
3064
+ () => visibleLinesRef.current.indexOf(targetLine as MinimalTranscriptLine),
3065
+ { fromTop: true },
3066
+ );
2770
3067
  };
2771
3068
 
2772
3069
  const toggleCurrentNewsRead = () => {
@@ -3551,6 +3848,99 @@ export function App({
3551
3848
  return true;
3552
3849
  };
3553
3850
 
3851
+ const appendRequesterLine = (requesterAgentId: string | null, line: Line) => {
3852
+ if (requesterAgentId === null || !subagentManager.getAgent(requesterAgentId)) {
3853
+ appendMainLine(line);
3854
+ } else {
3855
+ subagentManager.appendAgentLine(requesterAgentId, line);
3856
+ }
3857
+ };
3858
+
3859
+ /** Start a fresh managed child without occupying or steering the requester. */
3860
+ const runBackgroundCommand = (
3861
+ text: string,
3862
+ requesterAgentId: string | null,
3863
+ draftText = text,
3864
+ ): boolean => {
3865
+ const command = parseBackgroundCommand(text);
3866
+ if (!command) return false;
3867
+ const requesterKey = requesterAgentId ?? "main";
3868
+ const restoreDraft = () => {
3869
+ if (activeAgentIdRef.current !== requesterAgentId) {
3870
+ if (!(viewDrafts.current.get(requesterKey) ?? "")) {
3871
+ viewDrafts.current.set(requesterKey, draftText);
3872
+ }
3873
+ return;
3874
+ }
3875
+ if (!(inputRef.current?.plainText ?? "")) {
3876
+ viewDrafts.current.set(requesterKey, draftText);
3877
+ setEditorText(draftText, draftText.length, true);
3878
+ }
3879
+ };
3880
+ if (command.kind === "error") {
3881
+ setEditorText(draftText, draftText.length, true);
3882
+ appendRequesterLine(requesterAgentId, {
3883
+ kind: "text",
3884
+ role: "error",
3885
+ text: command.message,
3886
+ });
3887
+ return true;
3888
+ }
3889
+ if (sessionSwitchRef.current) {
3890
+ setEditorText(draftText, draftText.length, true);
3891
+ appendRequesterLine(requesterAgentId, {
3892
+ kind: "text",
3893
+ role: "error",
3894
+ text: "wait for the session change to finish before starting a background agent",
3895
+ });
3896
+ return true;
3897
+ }
3898
+ if (relocatingRef.current || pendingRelocationRef.current) {
3899
+ setEditorText(draftText, draftText.length, true);
3900
+ appendRequesterLine(requesterAgentId, {
3901
+ kind: "text",
3902
+ role: "error",
3903
+ text: "wait for the worktree move to finish before starting a background agent",
3904
+ });
3905
+ return true;
3906
+ }
3907
+
3908
+ setEditingStash(null);
3909
+ viewDrafts.current.set(requesterKey, "");
3910
+ setEditorText("");
3911
+ histCursor.current = null;
3912
+ draft.current = "";
3913
+ void (async () => {
3914
+ // The registry and every child session belong to the active main session.
3915
+ // Bind it before spawning so an App-start race cannot persist elsewhere.
3916
+ await subagentManager.bindMainSession(session.sessionManager, cwd);
3917
+ const spawned = requesterAgentId === null
3918
+ ? await subagentManager.spawnBackground({
3919
+ task: command.prompt,
3920
+ requesterAgentId: null,
3921
+ modelId: `${session.agent.state.model.provider}/${session.agent.state.model.id}`,
3922
+ thinkingLevel: String(session.agent.state.thinkingLevel),
3923
+ })
3924
+ : await subagentManager.spawnBackground({
3925
+ task: command.prompt,
3926
+ requesterAgentId,
3927
+ });
3928
+ appendRequesterLine(requesterAgentId, {
3929
+ kind: "text",
3930
+ role: "system",
3931
+ text: `background agent started: ${spawned.name} (${spawned.id})\n${spawned.worktree.branch}\n${spawned.worktree.path}`,
3932
+ });
3933
+ })().catch((error) => {
3934
+ appendRequesterLine(requesterAgentId, {
3935
+ kind: "text",
3936
+ role: "error",
3937
+ text: `background agent could not start: ${String(error)}`,
3938
+ });
3939
+ restoreDraft();
3940
+ });
3941
+ return true;
3942
+ };
3943
+
3554
3944
  const submitPrompt = (value?: string, stashIndex?: number) => {
3555
3945
  // Read the selected agent from the ref, not the state. A view switch updates
3556
3946
  // the ref synchronously, but a switch-then-send in one input chunk runs
@@ -3578,6 +3968,26 @@ export function App({
3578
3968
 
3579
3969
  if (!promptText && attachments.length === 0 && pastedTexts.length === 0) return;
3580
3970
 
3971
+ const backgroundCandidate = commandEligible ? parseBackgroundCommand(promptText) : null;
3972
+ if (backgroundCandidate?.kind === "error") {
3973
+ setEditorText(rawDisplayText, rawDisplayText.length, true);
3974
+ appendRequesterLine(selectedAgentId, {
3975
+ kind: "text",
3976
+ role: "error",
3977
+ text: backgroundCandidate.message,
3978
+ });
3979
+ return;
3980
+ }
3981
+ if (backgroundCandidate && (attachments.length > 0 || pastedTexts.length > 0)) {
3982
+ setEditorText(rawDisplayText, rawDisplayText.length, true);
3983
+ appendRequesterLine(selectedAgentId, {
3984
+ kind: "text",
3985
+ role: "error",
3986
+ text: "/background accepts text only; remove image and pasted-text attachments",
3987
+ });
3988
+ return;
3989
+ }
3990
+
3581
3991
  // The main session is being replaced, so keep the draft rather than deliver
3582
3992
  // into a session that is about to be aborted and disposed.
3583
3993
  if (!selectedAgentId && sessionSwitchRef.current) {
@@ -3659,6 +4069,17 @@ export function App({
3659
4069
  return;
3660
4070
  }
3661
4071
 
4072
+ // /background belongs to the selected transcript, unlike the main-only
4073
+ // command router below, and must never become an ordinary child message.
4074
+ if (
4075
+ attachments.length === 0
4076
+ && commandEligible
4077
+ && runBackgroundCommand(promptText, selectedAgentId, rawDisplayText)
4078
+ ) {
4079
+ if (!selectedAgentId) appendCommandHistory();
4080
+ return;
4081
+ }
4082
+
3662
4083
  // AFK is process-global, so it is intercepted above the child routing below.
3663
4084
  // Successful command handling still makes the entered command recallable.
3664
4085
  if (attachments.length === 0 && commandEligible && runAfkCommand(promptText)) {
@@ -4053,8 +4474,65 @@ export function App({
4053
4474
  } else queueMicrotask(() => inputRef.current?.focus());
4054
4475
  };
4055
4476
 
4477
+ /**
4478
+ * Put a row in the tree so it can be scrolled to.
4479
+ *
4480
+ * Only rows near the end are mounted, so anything that scrolls to a row has
4481
+ * to ask for it first. The floor keeps it mounted: without one, the very next
4482
+ * frame could decide the reader is still at the end and drop it again before
4483
+ * React had rendered it.
4484
+ */
4485
+ const ensureTranscriptRowMounted = (index: number) => {
4486
+ const next = windowStartForRow(transcriptWindowStartRef.current, index);
4487
+ if (next >= transcriptWindowStartRef.current) return;
4488
+ transcriptWindowStartRef.current = next;
4489
+ transcriptWindowFloorRef.current = Math.min(transcriptWindowFloorRef.current, next);
4490
+ setTranscriptWindowTick((tick) => tick + 1);
4491
+ };
4492
+
4493
+ /**
4494
+ * Scroll to a row once React has drawn it.
4495
+ *
4496
+ * A row that had to be mounted first is not in the tree at microtask time,
4497
+ * and a long transcript can take several frames to draw it, so keep asking
4498
+ * rather than asking once. `wanted` stops a walk that has moved on: the
4499
+ * reader holding a key starts one of these per row, and the older ones must
4500
+ * not drag the view back to where the walk began.
4501
+ */
4502
+ const scrollToTranscriptRow = (
4503
+ resolve: () => number,
4504
+ options: { fromTop?: boolean; wanted?: () => boolean } = {},
4505
+ ) => {
4506
+ transcriptRevealsRef.current++;
4507
+ let tries = 0;
4508
+ const done = () => {
4509
+ transcriptRevealsRef.current = Math.max(0, transcriptRevealsRef.current - 1);
4510
+ };
4511
+ const reveal = () => {
4512
+ if (options.wanted && !options.wanted()) return done();
4513
+ const index = resolve();
4514
+ const scroll = transcriptScrollRef.current;
4515
+ if (scroll && index >= 0) {
4516
+ // Asked for again on every try: the row is only held by the window
4517
+ // while something wants it, and the frame that runs in between is free
4518
+ // to decide the reader is at the end of the transcript.
4519
+ ensureTranscriptRowMounted(index);
4520
+ if (scroll.findDescendantById(`transcript-line-${index}`)) {
4521
+ // From the top, the row lands on the first screen row instead of the
4522
+ // last: `scrollChildIntoView` moves as little as it can.
4523
+ if (options.fromTop) scroll.scrollTop = 0;
4524
+ scroll.scrollChildIntoView(`transcript-line-${index}`);
4525
+ return done();
4526
+ }
4527
+ }
4528
+ if (tries++ < ROW_DRAW_TRIES) setTimeout(reveal, ROW_DRAW_RETRY_MS);
4529
+ else done();
4530
+ };
4531
+ queueMicrotask(reveal);
4532
+ };
4533
+
4056
4534
  const revealTranscriptCursor = (index: number) => {
4057
- queueMicrotask(() => transcriptScrollRef.current?.scrollChildIntoView(`transcript-line-${index}`));
4535
+ scrollToTranscriptRow(() => index, { wanted: () => transcriptCursorRef.current === index });
4058
4536
  };
4059
4537
 
4060
4538
  /**
@@ -4066,6 +4544,7 @@ export function App({
4066
4544
  * The layout runs after React commits, hence the second, later attempt.
4067
4545
  */
4068
4546
  const anchorTranscriptRow = (index: number) => {
4547
+ ensureTranscriptRowMounted(index);
4069
4548
  const apply = () => {
4070
4549
  const scroll = transcriptScrollRef.current;
4071
4550
  const row = scroll?.findDescendantById(`transcript-line-${index}`);
@@ -4115,6 +4594,9 @@ export function App({
4115
4594
  selectTranscriptRow(index);
4116
4595
  toggleTranscriptDetail(index);
4117
4596
  };
4597
+ // Rows are memoized, so the handler they receive has to keep one identity for
4598
+ // the life of the app. The ref carries the current closure behind it.
4599
+ clickTranscriptDisclosureRef.current = clickTranscriptDisclosure;
4118
4600
 
4119
4601
  const copyTranscriptRow = () => {
4120
4602
  const line = visibleLines[transcriptCursorRef.current];
@@ -4626,9 +5108,12 @@ export function App({
4626
5108
  isReturnKey && !hasCtrlForReturn && !hasShiftForReturn && !hasAltForReturn;
4627
5109
  const inputValue = inputRef.current?.plainText ?? "";
4628
5110
  const commandMatches =
4629
- shellModeRef.current || activeAgentIdRef.current || stashOpenRef.current || commandSuggestionsDismissedRef.current
5111
+ shellModeRef.current || stashOpenRef.current || commandSuggestionsDismissedRef.current
4630
5112
  ? []
4631
- : matchingCommands(inputValue).slice(0, 5);
5113
+ : matchingCommandsForTarget(
5114
+ inputValue,
5115
+ activeAgentIdRef.current ? "subagent" : "main",
5116
+ ).slice(0, 5);
4632
5117
  const inputCursor = inputRef.current?.cursorOffset ?? inputValue.length;
4633
5118
  const pathMatches =
4634
5119
  (!shellModeRef.current && activeAgentIdRef.current)
@@ -4756,7 +5241,7 @@ export function App({
4756
5241
  queueMicrotask(() => inputRef.current?.focus());
4757
5242
  return;
4758
5243
  }
4759
- if (!activeAgentId && commandMatches.length > 0 && !/\s/.test(inputValue)) {
5244
+ if (commandMatches.length > 0 && !/\s/.test(inputValue)) {
4760
5245
  key.stopPropagation();
4761
5246
  const selected = commandMatches[Math.min(commandCursorRef.current, commandMatches.length - 1)]!;
4762
5247
  setEditorText(selected.name);
@@ -5002,6 +5487,59 @@ export function App({
5002
5487
  ? needsTranscriptGap(lastLine, { kind: "text", role: visibleTx.stream.kind, text: visibleTx.stream.text })
5003
5488
  : false;
5004
5489
 
5490
+ // One element for the whole transcript, rebuilt only when what it shows
5491
+ // changes. React walks a child list of this size on every render of the app,
5492
+ // and an answer arriving mid-turn re-renders the app many times a second, so
5493
+ // handing back the identical element lets React skip the list entirely.
5494
+ // Every value a row reads is a dependency below. Add the dependency when a
5495
+ // row starts reading something new, or the rows go stale.
5496
+ // The stream is reduced to a flag on purpose: only the caret on the last row
5497
+ // depends on it, so a delta must not rebuild the settled rows.
5498
+ const streaming = visibleTx.stream !== null;
5499
+ const transcriptRows = useMemo(
5500
+ () => <>{visibleLines.slice(transcriptWindowStart).map((line, offset) => {
5501
+ // The absolute index, not the offset in the window: the row ids, the
5502
+ // transcript cursor, and the gap rule are all in terms of the whole
5503
+ // transcript, and they must not change when older rows mount.
5504
+ const i = transcriptWindowStart + offset;
5505
+ const projectedKey = projectedLineKey(line, i);
5506
+ return (
5507
+ <TranscriptRow
5508
+ key={projectedKey}
5509
+ theme={theme}
5510
+ syntaxStyle={syntaxStyle}
5511
+ line={line}
5512
+ index={i}
5513
+ selected={transcriptFocused && transcriptCursor === i}
5514
+ expanded={detailOverrides.get(projectedKey) ?? outputMode === "verbose"}
5515
+ outputMode={outputMode}
5516
+ workingCaret={visibleBusy && !streaming && i === visibleLines.length - 1}
5517
+ gapBefore={needsTranscriptGap(visibleLines[i - 1], line)}
5518
+ news={
5519
+ line.kind === "text" && line.role === "assistant" && line.newsId
5520
+ ? (newsReadById.get(line.newsId) ? "seen" : "unseen")
5521
+ : undefined
5522
+ }
5523
+ onDisclosure={onTranscriptDisclosure}
5524
+ />
5525
+ );
5526
+ })}</>,
5527
+ [
5528
+ visibleLines,
5529
+ transcriptWindowStart,
5530
+ theme,
5531
+ syntaxStyle,
5532
+ outputMode,
5533
+ detailOverrides,
5534
+ transcriptFocused,
5535
+ transcriptCursor,
5536
+ visibleBusy,
5537
+ streaming,
5538
+ newsReadById,
5539
+ onTranscriptDisclosure,
5540
+ ],
5541
+ );
5542
+
5005
5543
  return (
5006
5544
  <AnimationProvider
5007
5545
  enabled={animations}
@@ -5049,69 +5587,12 @@ export function App({
5049
5587
  style={{ flexGrow: 1, paddingLeft: 1, paddingRight: 1 }}
5050
5588
  stickyScroll
5051
5589
  stickyStart="bottom"
5590
+ onMouseDragEnd={onTranscriptReaderDrag}
5591
+ onMouseDrop={onTranscriptReaderDrag}
5052
5592
  verticalScrollbarOptions={{ visible: true }}
5053
5593
  >
5054
5594
  <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
- })}
5595
+ {transcriptRows}
5115
5596
  {visibleTx.stream ? (
5116
5597
  <>
5117
5598
  {/* 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
+ }