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 +1 -1
- package/src/animation.tsx +9 -6
- package/src/app.tsx +563 -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,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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
const commandSuggestions = shellMode ||
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
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
|
-
|
|
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 ||
|
|
5111
|
+
shellModeRef.current || stashOpenRef.current || commandSuggestionsDismissedRef.current
|
|
4630
5112
|
? []
|
|
4631
|
-
:
|
|
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 (
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
+
}
|