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 +1 -1
- package/src/animation.tsx +9 -6
- package/src/app.tsx +569 -82
- package/src/background-command.ts +24 -0
- package/src/commands.ts +19 -6
- package/src/help-popup.tsx +1 -0
- package/src/subagents/manager.ts +63 -1
- package/src/transcript-window.ts +108 -0
package/package.json
CHANGED
package/src/animation.tsx
CHANGED
|
@@ -285,13 +285,16 @@ export function AnimationProvider({
|
|
|
285
285
|
);
|
|
286
286
|
const workingRuleCycleWidth = useCallback(() => workingCycleWidth.current, []);
|
|
287
287
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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 {
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
const commandSuggestions = shellMode ||
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
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
|
-
|
|
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 ||
|
|
5117
|
+
shellModeRef.current || stashOpenRef.current || commandSuggestionsDismissedRef.current
|
|
4630
5118
|
? []
|
|
4631
|
-
:
|
|
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 (
|
|
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
|
-
{
|
|
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
|
-
|
|
77
|
-
|
|
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(
|
|
82
|
-
return COMMANDS.filter((command) =>
|
|
83
|
-
|
|
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 {
|
package/src/help-popup.tsx
CHANGED
|
@@ -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)"],
|
package/src/subagents/manager.ts
CHANGED
|
@@ -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)
|
|
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
|
+
}
|