@yandy0725/pi-subagents 0.1.0 → 0.2.2
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 +5 -7
- package/src/handlers/lifecycle.ts +16 -2
- package/src/index.ts +3 -1
- package/src/lifecycle/subagent-manager.ts +19 -1
- package/src/lifecycle/subagent-state.ts +1 -1
- package/src/lifecycle/subagent.ts +4 -0
- package/src/observation/notification.ts +5 -2
- package/src/observation/renderer.ts +5 -4
- package/src/observation/subagent-events-observer.ts +3 -0
- package/src/service/service-adapter.ts +10 -0
- package/src/service/service.ts +2 -0
- package/src/session/conversation.ts +1 -1
- package/src/session/prompts.ts +2 -2
- package/src/session/recover-subagents.ts +109 -0
- package/src/tools/agent-tool.ts +1 -1
- package/src/tools/result-renderer.ts +13 -13
- package/src/tools/spawn-config.ts +14 -8
- package/src/types.ts +1 -1
- package/src/ui/agent-widget.ts +4 -3
- package/src/ui/display.ts +3 -3
- package/src/ui/session-navigation.ts +7 -2
- package/src/ui/session-navigator.ts +129 -21
- package/src/ui/widget-renderer.ts +8 -4
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.2.2",
|
|
7
7
|
"description": "Focused, in-process sub-agent core for pi — autonomous agents plus a typed API and lifecycle events",
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"repository": {
|
|
@@ -33,12 +33,10 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
|
-
"@earendil-works/pi-ai": ">=0.
|
|
37
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
38
|
-
"@earendil-works/pi-tui": ">=0.
|
|
39
|
-
|
|
40
|
-
"dependencies": {
|
|
41
|
-
"@sinclair/typebox": "^0.34.49"
|
|
36
|
+
"@earendil-works/pi-ai": ">=0.80.2",
|
|
37
|
+
"@earendil-works/pi-coding-agent": ">=0.80.2",
|
|
38
|
+
"@earendil-works/pi-tui": ">=0.80.2",
|
|
39
|
+
"@sinclair/typebox": "*"
|
|
42
40
|
},
|
|
43
41
|
"devDependencies": {
|
|
44
42
|
"@biomejs/biome": "^2.5.0",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EvictedSubagent } from "../lifecycle/subagent-manager";
|
|
1
2
|
import type { SessionContext } from "../types";
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -12,6 +13,8 @@ export interface LifecycleManager {
|
|
|
12
13
|
clearCompleted(): void;
|
|
13
14
|
abortAll(): void;
|
|
14
15
|
dispose(): void;
|
|
16
|
+
/** Repopulate evicted descriptors recovered from disk on session start. */
|
|
17
|
+
restoreEvicted(descriptors: readonly EvictedSubagent[]): void;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
/** Narrow runtime interface — only the methods lifecycle handlers call. */
|
|
@@ -20,14 +23,20 @@ export interface LifecycleRuntime {
|
|
|
20
23
|
clearSessionContext(): void;
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
/** Recovers evicted subagent descriptors from the parent session file. */
|
|
27
|
+
export type RecoverEvicted = (parentSessionFile: string | undefined) => EvictedSubagent[];
|
|
28
|
+
|
|
23
29
|
/**
|
|
24
30
|
* Handles session lifecycle events.
|
|
25
31
|
*
|
|
26
32
|
* Constructor deps:
|
|
27
33
|
* - `runtime` — owns session context state
|
|
28
|
-
* - `manager` — manages agent lifecycle (clear, abort, dispose)
|
|
34
|
+
* - `manager` — manages agent lifecycle (clear, abort, dispose, restore)
|
|
29
35
|
* - `disposeNotifications` — tears down the notification system on shutdown
|
|
30
36
|
* - `unpublishService` — unpublishes the SubagentsService symbol on shutdown
|
|
37
|
+
* - `recoverEvicted` — rebuilds navigable descriptors from the parent session
|
|
38
|
+
* file so `/subagents:sessions` works after resume/fork (the extension is
|
|
39
|
+
* re-instantiated per session, so the in-memory evicted map starts empty)
|
|
31
40
|
*/
|
|
32
41
|
export class SessionLifecycleHandler {
|
|
33
42
|
constructor(
|
|
@@ -35,11 +44,16 @@ export class SessionLifecycleHandler {
|
|
|
35
44
|
private readonly manager: LifecycleManager,
|
|
36
45
|
private readonly disposeNotifications: () => void,
|
|
37
46
|
private readonly unpublishService: () => void,
|
|
47
|
+
private readonly recoverEvicted: RecoverEvicted,
|
|
38
48
|
) {}
|
|
39
49
|
|
|
40
50
|
handleSessionStart(_event: unknown, ctx: unknown): void {
|
|
41
|
-
|
|
51
|
+
const sessionCtx = ctx as SessionContext;
|
|
52
|
+
this.runtime.setSessionContext(sessionCtx);
|
|
42
53
|
this.manager.clearCompleted();
|
|
54
|
+
// Re-discover persisted subagent sessions from the parent session file so
|
|
55
|
+
// they remain viewable after resume/fork (the in-memory map starts empty).
|
|
56
|
+
this.manager.restoreEvicted(this.recoverEvicted(sessionCtx.sessionManager?.getSessionFile()));
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
handleSessionBeforeSwitch(): void {
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Commands:
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
14
|
import {
|
|
15
15
|
createAgentSession,
|
|
16
16
|
DefaultResourceLoader,
|
|
@@ -37,6 +37,7 @@ import { detectEnv } from "./session/env";
|
|
|
37
37
|
|
|
38
38
|
import { resolveModel } from "./session/model-resolver";
|
|
39
39
|
import { buildAgentPrompt } from "./session/prompts";
|
|
40
|
+
import { recoverEvictedSubagents } from "./session/recover-subagents";
|
|
40
41
|
import { deriveSubagentSessionDir } from "./session/session-dir";
|
|
41
42
|
import { SettingsManager } from "./settings";
|
|
42
43
|
import { AgentTool } from "./tools/agent-tool";
|
|
@@ -124,6 +125,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
124
125
|
manager,
|
|
125
126
|
() => notifications.dispose(),
|
|
126
127
|
unpublishSubagentsService,
|
|
128
|
+
(parentSessionFile) => recoverEvictedSubagents(parentSessionFile, (path) => readFileSync(path, "utf8"), existsSync),
|
|
127
129
|
);
|
|
128
130
|
|
|
129
131
|
pi.on("session_start", (event, ctx) => lifecycle.handleSessionStart(event, ctx));
|
|
@@ -36,6 +36,8 @@ export interface EvictedSubagent {
|
|
|
36
36
|
readonly startedAt: number;
|
|
37
37
|
readonly completedAt: number | undefined;
|
|
38
38
|
readonly toolUses: number;
|
|
39
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
40
|
+
readonly modelName?: string;
|
|
39
41
|
readonly outputFile: string;
|
|
40
42
|
}
|
|
41
43
|
|
|
@@ -132,7 +134,7 @@ export class SubagentManager {
|
|
|
132
134
|
this.observer?.onSubagentStarted(agent);
|
|
133
135
|
},
|
|
134
136
|
onSessionCreated: options.observer?.onSessionCreated
|
|
135
|
-
? (agent) => options.observer
|
|
137
|
+
? (agent) => options.observer?.onSessionCreated?.(agent)
|
|
136
138
|
: undefined,
|
|
137
139
|
onRunFinished: (agent) => {
|
|
138
140
|
if (options.isBackground) {
|
|
@@ -237,6 +239,21 @@ export class SubagentManager {
|
|
|
237
239
|
return [...this.evicted.values()].sort((a, b) => b.startedAt - a.startedAt);
|
|
238
240
|
}
|
|
239
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Repopulate evicted descriptors recovered from disk (the parent session's
|
|
244
|
+
* `subagents:record` entries) on session start/resume/fork — the in-memory
|
|
245
|
+
* map starts empty because the extension is re-instantiated per session.
|
|
246
|
+
*
|
|
247
|
+
* Descriptors whose id still has a live record are dropped: the live record
|
|
248
|
+
* (and its real-time transcript) wins over the stale on-disk descriptor.
|
|
249
|
+
*/
|
|
250
|
+
restoreEvicted(descriptors: readonly EvictedSubagent[]): void {
|
|
251
|
+
for (const descriptor of descriptors) {
|
|
252
|
+
if (this.agents.has(descriptor.id)) continue; // live record wins
|
|
253
|
+
this.evicted.set(descriptor.id, descriptor);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
240
257
|
abort(id: string): boolean {
|
|
241
258
|
const record = this.agents.get(id);
|
|
242
259
|
if (!record) return false;
|
|
@@ -348,6 +365,7 @@ function toEvictedSubagent(record: Subagent, outputFile: string): EvictedSubagen
|
|
|
348
365
|
startedAt: record.startedAt,
|
|
349
366
|
completedAt: record.completedAt,
|
|
350
367
|
toolUses: record.toolUses,
|
|
368
|
+
modelName: record.invocation?.modelName,
|
|
351
369
|
outputFile,
|
|
352
370
|
};
|
|
353
371
|
}
|
|
@@ -122,7 +122,7 @@ export class SubagentState {
|
|
|
122
122
|
|
|
123
123
|
/** Record a tool starting. Called by record-observer on tool_execution_start. */
|
|
124
124
|
addActiveTool(toolName: string): void {
|
|
125
|
-
this._activeTools.set(toolName
|
|
125
|
+
this._activeTools.set(`${toolName}_${++this._toolKeySeq}`, toolName);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/** Remove one active tool by name (first match). Called by record-observer on tool_execution_end. */
|
|
@@ -129,6 +129,10 @@ export class Subagent {
|
|
|
129
129
|
get maxTurns(): number | undefined {
|
|
130
130
|
return this.execution.maxTurns;
|
|
131
131
|
}
|
|
132
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
133
|
+
get modelName(): string | undefined {
|
|
134
|
+
return this.invocation?.modelName;
|
|
135
|
+
}
|
|
132
136
|
|
|
133
137
|
readonly abortController: AbortController;
|
|
134
138
|
private _promise?: Promise<void>;
|
|
@@ -15,6 +15,8 @@ export interface NotificationDetails {
|
|
|
15
15
|
outputFile?: string;
|
|
16
16
|
error?: string;
|
|
17
17
|
resultPreview: string;
|
|
18
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
19
|
+
modelName?: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
// ---- Pure helpers (exported for unit testing) ----
|
|
@@ -51,7 +53,7 @@ export function formatTaskNotification(record: Subagent, resultMaxLen: number):
|
|
|
51
53
|
|
|
52
54
|
const resultPreview = record.result
|
|
53
55
|
? record.result.length > resultMaxLen
|
|
54
|
-
? record.result.slice(0, resultMaxLen)
|
|
56
|
+
? `${record.result.slice(0, resultMaxLen)}\n...(truncated, use get_subagent_result for full output)`
|
|
55
57
|
: record.result
|
|
56
58
|
: "No output.";
|
|
57
59
|
|
|
@@ -87,9 +89,10 @@ export function buildNotificationDetails(record: Subagent, resultMaxLen: number)
|
|
|
87
89
|
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
88
90
|
outputFile: record.outputFile,
|
|
89
91
|
error: record.error,
|
|
92
|
+
modelName: record.modelName,
|
|
90
93
|
resultPreview: record.result
|
|
91
94
|
? record.result.length > resultMaxLen
|
|
92
|
-
? record.result.slice(0, resultMaxLen)
|
|
95
|
+
? `${record.result.slice(0, resultMaxLen)}…`
|
|
93
96
|
: record.result
|
|
94
97
|
: "No output.",
|
|
95
98
|
};
|
|
@@ -36,26 +36,27 @@ export function createNotificationRenderer() {
|
|
|
36
36
|
|
|
37
37
|
// Line 2: stats
|
|
38
38
|
const parts: string[] = [];
|
|
39
|
+
if (d.modelName) parts.push(d.modelName);
|
|
39
40
|
if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
|
|
40
41
|
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
41
42
|
if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
|
|
42
43
|
if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
|
|
43
44
|
if (parts.length) {
|
|
44
|
-
line +=
|
|
45
|
+
line += `\n ${parts.map((p) => theme.fg("dim", p)).join(` ${theme.fg("dim", "·")} `)}`;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
// Line 3: result preview (collapsed) or full (expanded)
|
|
48
49
|
if (expanded) {
|
|
49
50
|
const lines = d.resultPreview.split("\n").slice(0, 30);
|
|
50
|
-
for (const l of lines) line +=
|
|
51
|
+
for (const l of lines) line += `\n${theme.fg("dim", ` ${l}`)}`;
|
|
51
52
|
} else {
|
|
52
53
|
const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? "";
|
|
53
|
-
line +=
|
|
54
|
+
line += `\n ${theme.fg("dim", `⎿ ${preview}`)}`;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
// Line 4: output file link (if present)
|
|
57
58
|
if (d.outputFile) {
|
|
58
|
-
line +=
|
|
59
|
+
line += `\n ${theme.fg("muted", `transcript: ${d.outputFile}`)}`;
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
return new Text(line, 0, 0);
|
|
@@ -62,6 +62,9 @@ export class SubagentEventsObserver implements SubagentManagerObserver {
|
|
|
62
62
|
error: record.error,
|
|
63
63
|
startedAt: record.startedAt,
|
|
64
64
|
completedAt: record.completedAt,
|
|
65
|
+
toolUses: record.toolUses,
|
|
66
|
+
modelName: record.modelName,
|
|
67
|
+
outputFile: record.outputFile,
|
|
65
68
|
});
|
|
66
69
|
|
|
67
70
|
// Skip notification if result was already consumed via get_subagent_result.
|
|
@@ -9,6 +9,7 @@ import type { ParentSnapshot } from "../lifecycle/parent-snapshot";
|
|
|
9
9
|
import type { WorkspaceProvider } from "../lifecycle/workspace";
|
|
10
10
|
import type { SpawnOptions, SubagentRecord, SubagentsService } from "../service/service";
|
|
11
11
|
import type { ModelRegistry } from "../session/model-resolver";
|
|
12
|
+
import { resolveModelName } from "../tools/spawn-config";
|
|
12
13
|
import type { SessionContext, Subagent } from "../types";
|
|
13
14
|
|
|
14
15
|
/** Narrow interface for the SubagentManager — avoids coupling to the concrete class. */
|
|
@@ -56,6 +57,11 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
56
57
|
}
|
|
57
58
|
model = resolved;
|
|
58
59
|
}
|
|
60
|
+
// Always compute display model name, even when same as parent
|
|
61
|
+
const modelName = resolveModelName(
|
|
62
|
+
(model as { id?: string; name?: string; provider?: string } | undefined) ??
|
|
63
|
+
(this.runtime.currentCtx.model as { id?: string; name?: string; provider?: string } | undefined),
|
|
64
|
+
);
|
|
59
65
|
|
|
60
66
|
const description = options?.description ?? prompt.slice(0, 80);
|
|
61
67
|
const isBackground = !(options?.foreground ?? false);
|
|
@@ -69,6 +75,9 @@ export class SubagentsServiceAdapter implements SubagentsService {
|
|
|
69
75
|
inheritContext: options?.inheritContext,
|
|
70
76
|
bypassQueue: options?.bypassQueue,
|
|
71
77
|
isBackground,
|
|
78
|
+
// Service API builds a display-minimal invocation (modelName only);
|
|
79
|
+
// the tool path additionally carries thinking/maxTurns/inherit tags.
|
|
80
|
+
invocation: modelName != null ? { modelName } : undefined,
|
|
72
81
|
});
|
|
73
82
|
}
|
|
74
83
|
|
|
@@ -126,6 +135,7 @@ export function toSubagentRecord(record: Subagent): SubagentRecord {
|
|
|
126
135
|
if (record.result !== undefined) out.result = record.result;
|
|
127
136
|
if (record.error !== undefined) out.error = record.error;
|
|
128
137
|
if (record.completedAt !== undefined) out.completedAt = record.completedAt;
|
|
138
|
+
if (record.modelName !== undefined) out.modelName = record.modelName;
|
|
129
139
|
|
|
130
140
|
return out;
|
|
131
141
|
}
|
package/src/service/service.ts
CHANGED
|
@@ -48,6 +48,8 @@ export interface SubagentRecord {
|
|
|
48
48
|
completedAt?: number;
|
|
49
49
|
lifetimeUsage: LifetimeUsage;
|
|
50
50
|
compactionCount: number;
|
|
51
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
52
|
+
modelName?: string;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
/** Options for spawning an agent via the service. */
|
|
@@ -27,7 +27,7 @@ export function getAgentConversation(session: AgentSession): string {
|
|
|
27
27
|
if (toolNames.length > 0) parts.push(`[Tool Calls]:\n${toolNames.map((n) => ` Tool: ${n}`).join("\n")}`);
|
|
28
28
|
} else if (msg.role === "toolResult") {
|
|
29
29
|
const text = extractText(msg.content);
|
|
30
|
-
const truncated = text.length > 200 ? text.slice(0, 200)
|
|
30
|
+
const truncated = text.length > 200 ? `${text.slice(0, 200)}...` : text;
|
|
31
31
|
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/session/prompts.ts
CHANGED
|
@@ -66,14 +66,14 @@ You are operating as a sub-agent invoked to handle a specific task.
|
|
|
66
66
|
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
|
|
67
67
|
// with the parent session, maximising KV cache hits. The <active_agent>
|
|
68
68
|
// tag and env block vary per call and are placed after the cached prefix.
|
|
69
|
-
return identity
|
|
69
|
+
return `${identity}\n\n${bridge}\n\n${activeAgentTag}${envBlock}${customSection}`;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// "replace" mode — parent/genericBase prefix first for KV cache reuse, then
|
|
73
73
|
// the active_agent tag, env block, and the config's full system prompt.
|
|
74
74
|
// Unlike append mode, no <sub_agent_context> bridge or <agent_instructions>
|
|
75
75
|
// wrapper is injected — the custom prompt retains full control.
|
|
76
|
-
return identity
|
|
76
|
+
return `${identity}\n\n${activeAgentTag}${envBlock}\n\n${config.systemPrompt}`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/** Fallback base prompt when parent system prompt is unavailable (both modes). */
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recover-subagents.ts — Rebuild navigable subagent descriptors from disk.
|
|
3
|
+
*
|
|
4
|
+
* When a session is resumed or forked, the in-memory `SubagentManager` starts
|
|
5
|
+
* empty (the extension is re-instantiated per session). The child sessions'
|
|
6
|
+
* transcripts still live on disk, and a `subagents:record` custom entry was
|
|
7
|
+
* appended to the *parent* session file on each completion — carrying the
|
|
8
|
+
* metadata (and, since this fix, the transcript `outputFile`) needed to label
|
|
9
|
+
* and source them. This module parses those entries back into `EvictedSubagent`
|
|
10
|
+
* descriptors so `/subagents:sessions` can show them again after resume/fork.
|
|
11
|
+
*
|
|
12
|
+
* Pure: takes the parent session file path, a `readFile` seam, and an optional
|
|
13
|
+
* `exists` seam (defaults to a readFile-based probe), returns descriptors. The
|
|
14
|
+
* default keeps the function free of direct `fs` calls; callers may pass
|
|
15
|
+
* `fs.existsSync` to avoid reading the whole file just to check existence.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { type CustomEntry, parseSessionEntries } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { EvictedSubagent } from "../lifecycle/subagent-manager";
|
|
21
|
+
import type { SubagentStatus } from "../lifecycle/subagent-state";
|
|
22
|
+
import type { SubagentType } from "../types";
|
|
23
|
+
import { deriveSubagentSessionDir } from "./session-dir";
|
|
24
|
+
|
|
25
|
+
/** The customType under which completed-subagent records are persisted. */
|
|
26
|
+
export const SUBAGENT_RECORD_CUSTOM_TYPE = "subagents:record";
|
|
27
|
+
|
|
28
|
+
/** Fields persisted on each `subagents:record` entry (see onSubagentCompleted). */
|
|
29
|
+
interface PersistedSubagentRecord {
|
|
30
|
+
id: string;
|
|
31
|
+
type: SubagentType;
|
|
32
|
+
description: string;
|
|
33
|
+
status: SubagentStatus;
|
|
34
|
+
result?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
startedAt: number;
|
|
37
|
+
completedAt: number | undefined;
|
|
38
|
+
toolUses?: number;
|
|
39
|
+
modelName?: string;
|
|
40
|
+
outputFile?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read the parent session file and rebuild `EvictedSubagent` descriptors from
|
|
45
|
+
* its `subagents:record` entries, most-recent-first. Records without an
|
|
46
|
+
* `outputFile` are dropped — their transcript is not navigable from disk.
|
|
47
|
+
*
|
|
48
|
+
* Returns `[]` when the parent session is not persisted (no file) or the file
|
|
49
|
+
* cannot be read, so callers can invoke unconditionally on session start.
|
|
50
|
+
*/
|
|
51
|
+
export function recoverEvictedSubagents(
|
|
52
|
+
parentSessionFile: string | undefined,
|
|
53
|
+
readFile: (path: string) => string,
|
|
54
|
+
exists: (path: string) => boolean = (path) => {
|
|
55
|
+
try {
|
|
56
|
+
readFile(path);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
): EvictedSubagent[] {
|
|
63
|
+
if (!parentSessionFile) return [];
|
|
64
|
+
let entries: ReturnType<typeof parseSessionEntries>;
|
|
65
|
+
try {
|
|
66
|
+
entries = parseSessionEntries(readFile(parentSessionFile));
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const records = entries.filter(
|
|
72
|
+
(entry): entry is CustomEntry =>
|
|
73
|
+
entry.type === "custom" && (entry as CustomEntry).customType === SUBAGENT_RECORD_CUSTOM_TYPE,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const recovered: EvictedSubagent[] = [];
|
|
77
|
+
// Derive the tasks directory once for fallback outputFile construction.
|
|
78
|
+
// The empty cwd is harmless: when parentSessionFile is set, session-dir
|
|
79
|
+
// derives the path from it and ignores cwd entirely.
|
|
80
|
+
const tasksDir = deriveSubagentSessionDir(parentSessionFile, "");
|
|
81
|
+
for (const entry of records) {
|
|
82
|
+
const data = (entry.data ?? {}) as PersistedSubagentRecord;
|
|
83
|
+
// Try to determine outputFile: prefer the persisted one, else construct
|
|
84
|
+
// from the agent id and tasks directory (backwards compat for pre-fix records).
|
|
85
|
+
let outputFile: string | undefined = data.outputFile;
|
|
86
|
+
if (!outputFile) {
|
|
87
|
+
const constructed = join(tasksDir, `${data.id}.jsonl`);
|
|
88
|
+
// Confirm the session file is readable so the file-snapshot source
|
|
89
|
+
// works later. The `exists` seam defaults to a readFile probe (pure);
|
|
90
|
+
// callers may pass `fs.existsSync` to avoid reading the whole file.
|
|
91
|
+
if (!exists(constructed)) continue; // session file doesn't exist — not navigable
|
|
92
|
+
outputFile = constructed;
|
|
93
|
+
}
|
|
94
|
+
recovered.push({
|
|
95
|
+
id: data.id,
|
|
96
|
+
type: data.type,
|
|
97
|
+
description: data.description,
|
|
98
|
+
status: data.status,
|
|
99
|
+
startedAt: data.startedAt,
|
|
100
|
+
completedAt: data.completedAt,
|
|
101
|
+
toolUses: data.toolUses ?? 0,
|
|
102
|
+
modelName: data.modelName,
|
|
103
|
+
outputFile,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Most-recent-first, matching listEvicted()'s ordering.
|
|
107
|
+
recovered.sort((a, b) => b.startedAt - a.startedAt);
|
|
108
|
+
return recovered;
|
|
109
|
+
}
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -192,7 +192,7 @@ Guidelines:
|
|
|
192
192
|
const displayName = args.subagent_type ? getDisplayName(args.subagent_type as string, registry) : "Subagent";
|
|
193
193
|
const desc = (args.description as string | undefined) ?? "";
|
|
194
194
|
return new Text(
|
|
195
|
-
|
|
195
|
+
`▸ ${theme.fg("toolTitle", theme.bold(displayName))}${desc ? ` ${theme.fg("muted", desc)}` : ""}`,
|
|
196
196
|
0,
|
|
197
197
|
0,
|
|
198
198
|
);
|
|
@@ -33,8 +33,8 @@ export function renderAgentResult(
|
|
|
33
33
|
export function renderRunning(details: AgentDetails, theme: Theme): string {
|
|
34
34
|
const frame = SPINNER[details.spinnerFrame ?? 0];
|
|
35
35
|
const s = renderStats(details, theme);
|
|
36
|
-
let line = theme.fg("accent", frame) + (s ?
|
|
37
|
-
line +=
|
|
36
|
+
let line = theme.fg("accent", frame) + (s ? ` ${s}` : "");
|
|
37
|
+
line += `\n${theme.fg("dim", ` ⎿ ${details.activity ?? "thinking\u2026"}`)}`;
|
|
38
38
|
return line;
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -49,22 +49,22 @@ export function renderCompleted(details: AgentDetails, resultText: string, expan
|
|
|
49
49
|
const isSteered = details.status === "steered";
|
|
50
50
|
const icon = isSteered ? theme.fg("warning", "\u2713") : theme.fg("success", "\u2713");
|
|
51
51
|
const s = renderStats(details, theme);
|
|
52
|
-
let line = icon + (s ?
|
|
53
|
-
line +=
|
|
52
|
+
let line = icon + (s ? ` ${s}` : "");
|
|
53
|
+
line += ` ${theme.fg("dim", "\u00B7")} ${theme.fg("dim", duration)}`;
|
|
54
54
|
|
|
55
55
|
if (expanded) {
|
|
56
56
|
if (resultText) {
|
|
57
57
|
const lines = resultText.split("\n").slice(0, 50);
|
|
58
58
|
for (const l of lines) {
|
|
59
|
-
line +=
|
|
59
|
+
line += `\n${theme.fg("dim", ` ${l}`)}`;
|
|
60
60
|
}
|
|
61
61
|
if (resultText.split("\n").length > 50) {
|
|
62
|
-
line +=
|
|
62
|
+
line += `\n${theme.fg("muted", " ... (use get_subagent_result with verbose for full output)")}`;
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
} else {
|
|
66
66
|
const doneText = isSteered ? "Wrapped up (turn limit)" : "Done";
|
|
67
|
-
line +=
|
|
67
|
+
line += `\n${theme.fg("dim", ` \u23BF ${doneText}`)}`;
|
|
68
68
|
}
|
|
69
69
|
return line;
|
|
70
70
|
}
|
|
@@ -72,20 +72,20 @@ export function renderCompleted(details: AgentDetails, resultText: string, expan
|
|
|
72
72
|
/** Render stopped status: dim stop icon + stats + "Stopped". */
|
|
73
73
|
export function renderStopped(details: AgentDetails, theme: Theme): string {
|
|
74
74
|
const s = renderStats(details, theme);
|
|
75
|
-
let line = theme.fg("dim", "\u25A0") + (s ?
|
|
76
|
-
line +=
|
|
75
|
+
let line = theme.fg("dim", "\u25A0") + (s ? ` ${s}` : "");
|
|
76
|
+
line += `\n${theme.fg("dim", " \u23BF Stopped")}`;
|
|
77
77
|
return line;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** Render error or aborted status: error icon + stats + status message. */
|
|
81
81
|
export function renderFailed(details: AgentDetails, theme: Theme): string {
|
|
82
82
|
const s = renderStats(details, theme);
|
|
83
|
-
let line = theme.fg("error", "\u2717") + (s ?
|
|
83
|
+
let line = theme.fg("error", "\u2717") + (s ? ` ${s}` : "");
|
|
84
84
|
|
|
85
85
|
if (details.status === "error") {
|
|
86
|
-
line +=
|
|
86
|
+
line += `\n${theme.fg("error", ` \u23BF Error: ${details.error ?? "unknown"}`)}`;
|
|
87
87
|
} else {
|
|
88
|
-
line +=
|
|
88
|
+
line += `\n${theme.fg("warning", " \u23BF Aborted (max turns exceeded)")}`;
|
|
89
89
|
}
|
|
90
90
|
return line;
|
|
91
91
|
}
|
|
@@ -105,5 +105,5 @@ export function renderStats(details: AgentDetails, theme: Theme): string {
|
|
|
105
105
|
}
|
|
106
106
|
if (details.toolUses > 0) parts.push(`${details.toolUses} tool use${details.toolUses === 1 ? "" : "s"}`);
|
|
107
107
|
if (details.tokens) parts.push(details.tokens);
|
|
108
|
-
return parts.map((p) => theme.fg("dim", p)).join(
|
|
108
|
+
return parts.map((p) => theme.fg("dim", p)).join(` ${theme.fg("dim", "\u00B7")} `);
|
|
109
109
|
}
|
|
@@ -15,9 +15,20 @@ import { resolveInvocationModel } from "../session/model-resolver";
|
|
|
15
15
|
import type { AgentInvocation, SubagentType, ThinkingLevel } from "../types";
|
|
16
16
|
import { type AgentDetails, buildInvocationTags, getDisplayName, getPromptModeLabel } from "../ui/display";
|
|
17
17
|
|
|
18
|
+
/** Derive a short display model name from a model object (or undefined). */
|
|
19
|
+
export function resolveModelName(
|
|
20
|
+
model: { id?: string; name?: string; provider?: string } | undefined,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
if (!model) return undefined;
|
|
23
|
+
// Primary: provider/id complete format
|
|
24
|
+
if (model.provider && model.id) return `${model.provider}/${model.id}`;
|
|
25
|
+
// Fallback: raw name or id, no transformation
|
|
26
|
+
return model.name ?? model.id ?? undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
18
29
|
/** Model info extracted from the parent session context. */
|
|
19
30
|
export interface ModelInfo {
|
|
20
|
-
parentModel: { id: string; name?: string } | undefined;
|
|
31
|
+
parentModel: { id: string; name?: string; provider?: string } | undefined;
|
|
21
32
|
modelRegistry: unknown;
|
|
22
33
|
}
|
|
23
34
|
|
|
@@ -103,13 +114,8 @@ export function resolveSpawnConfig(
|
|
|
103
114
|
const inheritContext = resolvedConfig.inheritContext;
|
|
104
115
|
const runInBackground = resolvedConfig.runInBackground;
|
|
105
116
|
|
|
106
|
-
// Compute display model name
|
|
107
|
-
const
|
|
108
|
-
const effectiveModelId = model?.id;
|
|
109
|
-
const modelName =
|
|
110
|
-
effectiveModelId && effectiveModelId !== parentModelId
|
|
111
|
-
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
112
|
-
: undefined;
|
|
117
|
+
// Compute display model name — always shown, even when same as parent.
|
|
118
|
+
const modelName = resolveModelName(model ?? modelInfo.parentModel);
|
|
113
119
|
|
|
114
120
|
const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? settings.defaultMaxTurns);
|
|
115
121
|
|
package/src/types.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface AgentConfig extends AgentIdentity, AgentPromptConfig {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export interface AgentInvocation {
|
|
66
|
-
/** Short display name
|
|
66
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
67
67
|
modelName?: string;
|
|
68
68
|
thinking?: ThinkingLevel;
|
|
69
69
|
maxTurns?: number;
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -180,6 +180,7 @@ export class AgentWidget implements SubagentManagerObserver {
|
|
|
180
180
|
activeTools: record.activeTools,
|
|
181
181
|
responseText: record.responseText,
|
|
182
182
|
contextPercent: record.getContextPercent(),
|
|
183
|
+
modelName: record.invocation?.modelName,
|
|
183
184
|
};
|
|
184
185
|
}
|
|
185
186
|
|
|
@@ -202,12 +203,12 @@ export class AgentWidget implements SubagentManagerObserver {
|
|
|
202
203
|
*/
|
|
203
204
|
private clearWidget(backgroundAgents: readonly AgentSummary[]): void {
|
|
204
205
|
if (this.widgetRegistered) {
|
|
205
|
-
this.uiCtx
|
|
206
|
+
this.uiCtx?.setWidget("agents", undefined);
|
|
206
207
|
this.widgetRegistered = false;
|
|
207
208
|
this.tui = undefined;
|
|
208
209
|
}
|
|
209
210
|
if (this.lastStatusText !== undefined) {
|
|
210
|
-
this.uiCtx
|
|
211
|
+
this.uiCtx?.setStatus("subagents", undefined);
|
|
211
212
|
this.lastStatusText = undefined;
|
|
212
213
|
}
|
|
213
214
|
if (this.widgetInterval) {
|
|
@@ -233,7 +234,7 @@ export class AgentWidget implements SubagentManagerObserver {
|
|
|
233
234
|
newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
|
|
234
235
|
}
|
|
235
236
|
if (newStatusText !== this.lastStatusText) {
|
|
236
|
-
this.uiCtx
|
|
237
|
+
this.uiCtx?.setStatus("subagents", newStatusText);
|
|
237
238
|
this.lastStatusText = newStatusText;
|
|
238
239
|
}
|
|
239
240
|
}
|
package/src/ui/display.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface AgentDetails {
|
|
|
28
28
|
activity?: string;
|
|
29
29
|
/** Current spinner frame index (for animated running indicator). */
|
|
30
30
|
spinnerFrame?: number;
|
|
31
|
-
/** Short model name
|
|
31
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
32
32
|
modelName?: string;
|
|
33
33
|
/** Notable config tags (e.g. ["thinking: high", "inherit context"]). */
|
|
34
34
|
tags?: string[];
|
|
@@ -142,7 +142,7 @@ function truncateLine(text: string, len = 60): string {
|
|
|
142
142
|
.find((l) => l.trim())
|
|
143
143
|
?.trim() ?? "";
|
|
144
144
|
if (line.length <= len) return line;
|
|
145
|
-
return line.slice(0, len)
|
|
145
|
+
return `${line.slice(0, len)}…`;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
/** Build a human-readable activity string from currently-running tools or response text. */
|
|
@@ -162,7 +162,7 @@ export function describeActivity(activeTools: ReadonlyMap<string, string>, respo
|
|
|
162
162
|
parts.push(action);
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
-
return parts.join(", ")
|
|
165
|
+
return `${parts.join(", ")}…`;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
// No tools active — show truncated response text if available
|
|
@@ -38,6 +38,8 @@ export interface NavigableSubagent {
|
|
|
38
38
|
readonly activeTools: ReadonlyMap<string, string>;
|
|
39
39
|
readonly responseText: string;
|
|
40
40
|
readonly agentMessages: readonly SessionMessage[];
|
|
41
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
42
|
+
readonly modelName?: string;
|
|
41
43
|
isSessionReady(): boolean;
|
|
42
44
|
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
|
|
43
45
|
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
@@ -51,7 +53,7 @@ export interface NavigableSubagent {
|
|
|
51
53
|
*/
|
|
52
54
|
export type NavigationEntry =
|
|
53
55
|
| { readonly kind: "live"; readonly label: string; readonly record: NavigableSubagent }
|
|
54
|
-
| { readonly kind: "evicted"; readonly label: string; readonly outputFile: string };
|
|
56
|
+
| { readonly kind: "evicted"; readonly label: string; readonly outputFile: string; readonly modelName?: string };
|
|
55
57
|
|
|
56
58
|
/** The fields `buildLabel` reads — shared by a live record and an evicted descriptor. */
|
|
57
59
|
interface LabelFields {
|
|
@@ -61,6 +63,7 @@ interface LabelFields {
|
|
|
61
63
|
readonly startedAt: number;
|
|
62
64
|
readonly completedAt: number | undefined;
|
|
63
65
|
readonly toolUses: number;
|
|
66
|
+
readonly modelName?: string;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/** Running-agent streaming state, surfaced by a live source. */
|
|
@@ -100,6 +103,7 @@ export function listNavigableAgents(
|
|
|
100
103
|
(descriptor): NavigationEntry => ({
|
|
101
104
|
kind: "evicted",
|
|
102
105
|
outputFile: descriptor.outputFile,
|
|
106
|
+
modelName: descriptor.modelName,
|
|
103
107
|
label: buildLabel(descriptor, registry, true),
|
|
104
108
|
}),
|
|
105
109
|
);
|
|
@@ -142,6 +146,7 @@ export function liveSource(record: NavigableSubagent): TranscriptSource {
|
|
|
142
146
|
function buildLabel(fields: LabelFields, registry: AgentConfigLookup, evicted = false): string {
|
|
143
147
|
const name = getDisplayName(fields.type, registry);
|
|
144
148
|
const duration = formatDuration(fields.startedAt, fields.completedAt);
|
|
149
|
+
const modelTag = fields.modelName ? ` · model:${fields.modelName}` : "";
|
|
145
150
|
const marker = evicted ? " · evicted (snapshot)" : "";
|
|
146
|
-
return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${marker}`;
|
|
151
|
+
return `${name} (${fields.description}) · ${fields.toolUses} tools · ${fields.status} · ${duration}${modelTag}${marker}`;
|
|
147
152
|
}
|
|
@@ -92,6 +92,8 @@ export interface TranscriptOverlayOptions {
|
|
|
92
92
|
done: (result: undefined) => void;
|
|
93
93
|
cwd: string;
|
|
94
94
|
markdownTheme: MarkdownTheme;
|
|
95
|
+
/** Short model name to show in the header, or undefined to omit. */
|
|
96
|
+
modelName?: string;
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/**
|
|
@@ -117,15 +119,23 @@ export class SessionNavigatorHandler {
|
|
|
117
119
|
if (!entry) return;
|
|
118
120
|
|
|
119
121
|
let source: TranscriptSource;
|
|
122
|
+
let modelName: string | undefined;
|
|
120
123
|
try {
|
|
121
|
-
|
|
124
|
+
if (entry.kind === "live") {
|
|
125
|
+
source = liveSource(entry.record);
|
|
126
|
+
modelName = entry.record.modelName;
|
|
127
|
+
} else {
|
|
128
|
+
source = fileSnapshotSource(entry.outputFile, readFile);
|
|
129
|
+
modelName = entry.modelName;
|
|
130
|
+
}
|
|
122
131
|
} catch {
|
|
123
132
|
ui.notify("Could not read the session transcript file.", "error");
|
|
124
133
|
return;
|
|
125
134
|
}
|
|
126
135
|
const markdownTheme = getMarkdownTheme();
|
|
127
136
|
await ui.custom<undefined>(
|
|
128
|
-
(tui, theme, _keybindings, done) =>
|
|
137
|
+
(tui, theme, _keybindings, done) =>
|
|
138
|
+
new TranscriptOverlay({ tui, theme, source, done, cwd, markdownTheme, modelName }),
|
|
129
139
|
{
|
|
130
140
|
overlay: true,
|
|
131
141
|
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
@@ -134,14 +144,34 @@ export class SessionNavigatorHandler {
|
|
|
134
144
|
}
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Minimum interval between two component rebuilds for a live (streaming) source.
|
|
149
|
+
*
|
|
150
|
+
* A running agent emits many events per second (text deltas, tool calls). Without
|
|
151
|
+
* throttling, each event rebuilt the whole component tree (markdown parsing
|
|
152
|
+
* included) and re-rendered it — starving the event loop so keystrokes (arrows,
|
|
153
|
+
* `q`) stopped responding. Coalescing bursts into at most one rebuild per tick
|
|
154
|
+
* keeps the overlay responsive while still streaming.
|
|
155
|
+
*/
|
|
156
|
+
const REBUILD_THROTTLE_MS = 120;
|
|
157
|
+
|
|
137
158
|
/**
|
|
138
159
|
* Read-only scrollable transcript overlay.
|
|
139
160
|
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* and
|
|
144
|
-
* `
|
|
161
|
+
* Two caches keep it responsive even for long, live-streaming transcripts:
|
|
162
|
+
*
|
|
163
|
+
* - `content` (a `Container` of Pi's per-entry components) is rebuilt only when
|
|
164
|
+
* the source changes, and rebuilds are *throttled* — a burst of streaming
|
|
165
|
+
* events coalesces into at most one rebuild per `REBUILD_THROTTLE_MS`, so
|
|
166
|
+
* markdown highlighting never re-runs on every token.
|
|
167
|
+
* - `renderedLines` caches the laid-out, width-wrapped lines. `render()` and
|
|
168
|
+
* `handleInput()` read the cache (O(1) slice / length) instead of
|
|
169
|
+
* re-rendering the whole container on every frame and every keystroke.
|
|
170
|
+
*
|
|
171
|
+
* The cache is invalidated (`linesDirty`) whenever the component tree changes,
|
|
172
|
+
* and recomputed lazily at the current width. This class owns scroll state,
|
|
173
|
+
* chrome, and the running-agent streaming indicator; the component mapping lives
|
|
174
|
+
* in `buildTranscriptComponents`.
|
|
145
175
|
*/
|
|
146
176
|
export class TranscriptOverlay implements Component {
|
|
147
177
|
private scrollOffset = 0;
|
|
@@ -155,21 +185,36 @@ export class TranscriptOverlay implements Component {
|
|
|
155
185
|
private readonly done: (result: undefined) => void;
|
|
156
186
|
private readonly cwd: string;
|
|
157
187
|
private readonly markdownTheme: MarkdownTheme;
|
|
188
|
+
private readonly modelName?: string;
|
|
158
189
|
private content: Container;
|
|
159
190
|
|
|
160
|
-
|
|
191
|
+
/** Throttle bookkeeping for coalescing live-source rebuilds. */
|
|
192
|
+
private rebuildTimer: ReturnType<typeof setTimeout> | undefined;
|
|
193
|
+
private lastRebuildAt = 0;
|
|
194
|
+
|
|
195
|
+
/** Cached laid-out lines + the width they were computed at; recomputed lazily when `linesDirty`. */
|
|
196
|
+
private renderedLines: string[] = [];
|
|
197
|
+
private renderedWidth = -1;
|
|
198
|
+
private linesDirty = true;
|
|
199
|
+
|
|
200
|
+
constructor({ tui, theme, source, done, cwd, markdownTheme, modelName }: TranscriptOverlayOptions) {
|
|
161
201
|
this.tui = tui;
|
|
162
202
|
this.theme = theme;
|
|
163
203
|
this.source = source;
|
|
164
204
|
this.done = done;
|
|
165
205
|
this.cwd = cwd;
|
|
166
206
|
this.markdownTheme = markdownTheme;
|
|
207
|
+
this.modelName = modelName;
|
|
167
208
|
this.content = this.rebuild();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
209
|
+
// Seed `lastRebuildAt` far in the past so the first source-change event
|
|
210
|
+
// always rebuilds immediately (leading-edge throttle). The constructor
|
|
211
|
+
// already built `content` from the snapshot at construction time, but
|
|
212
|
+
// the source may have accumulated events between construction and
|
|
213
|
+
// subscription — that first rebuild surfaces them without delay.
|
|
214
|
+
// Subsequent events inside the throttle window are coalesced into a
|
|
215
|
+
// single trailing rebuild.
|
|
216
|
+
this.lastRebuildAt = 0;
|
|
217
|
+
this.unsubscribe = source.subscribe(() => this.scheduleRebuild());
|
|
173
218
|
}
|
|
174
219
|
|
|
175
220
|
// fallow-ignore-next-line unused-class-member
|
|
@@ -180,29 +225,41 @@ export class TranscriptOverlay implements Component {
|
|
|
180
225
|
return;
|
|
181
226
|
}
|
|
182
227
|
|
|
183
|
-
const totalLines = this.
|
|
228
|
+
const totalLines = this.getRenderedLines(this.innerWidth()).length;
|
|
184
229
|
const viewportHeight = this.viewportHeight();
|
|
185
230
|
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
231
|
+
let scrolled = false;
|
|
186
232
|
|
|
187
233
|
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
188
234
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
189
|
-
|
|
235
|
+
// Streaming may add lines between handleInput and render,
|
|
236
|
+
// making maxScroll larger; unconditionally disable autoScroll
|
|
237
|
+
// so render() does not reset scrollOffset back to the bottom.
|
|
238
|
+
this.autoScroll = false;
|
|
239
|
+
scrolled = true;
|
|
190
240
|
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
191
241
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
192
|
-
this.autoScroll =
|
|
242
|
+
this.autoScroll = false;
|
|
243
|
+
scrolled = true;
|
|
193
244
|
} else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
194
245
|
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
195
246
|
this.autoScroll = false;
|
|
247
|
+
scrolled = true;
|
|
196
248
|
} else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
197
249
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
198
|
-
this.autoScroll =
|
|
250
|
+
this.autoScroll = false;
|
|
251
|
+
scrolled = true;
|
|
199
252
|
} else if (matchesKey(data, "home")) {
|
|
200
253
|
this.scrollOffset = 0;
|
|
201
254
|
this.autoScroll = false;
|
|
255
|
+
scrolled = true;
|
|
202
256
|
} else if (matchesKey(data, "end")) {
|
|
203
257
|
this.scrollOffset = maxScroll;
|
|
204
258
|
this.autoScroll = true;
|
|
259
|
+
scrolled = true;
|
|
205
260
|
}
|
|
261
|
+
|
|
262
|
+
if (scrolled) this.tui.requestRender();
|
|
206
263
|
}
|
|
207
264
|
|
|
208
265
|
render(width: number): string[] {
|
|
@@ -213,16 +270,17 @@ export class TranscriptOverlay implements Component {
|
|
|
213
270
|
|
|
214
271
|
const pad = (s: string, len: number): string => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
|
|
215
272
|
const row = (content: string): string =>
|
|
216
|
-
th.fg("border", "│")
|
|
273
|
+
`${th.fg("border", "│")} ${truncateToWidth(pad(content, innerW), innerW)} ${th.fg("border", "│")}`;
|
|
217
274
|
const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
|
|
218
275
|
const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
|
|
219
276
|
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
220
277
|
|
|
221
278
|
lines.push(hrTop);
|
|
222
|
-
|
|
279
|
+
const title = this.modelName ? `Subagent session · ${this.modelName}` : "Subagent session";
|
|
280
|
+
lines.push(row(th.bold(title)));
|
|
223
281
|
lines.push(hrMid);
|
|
224
282
|
|
|
225
|
-
const contentLines = this.
|
|
283
|
+
const contentLines = this.getRenderedLines(innerW);
|
|
226
284
|
const viewportHeight = this.viewportHeight();
|
|
227
285
|
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
228
286
|
if (this.autoScroll) this.scrollOffset = maxScroll;
|
|
@@ -236,7 +294,7 @@ export class TranscriptOverlay implements Component {
|
|
|
236
294
|
? "100%"
|
|
237
295
|
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
238
296
|
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
239
|
-
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn ·
|
|
297
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · end follow · q close");
|
|
240
298
|
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
241
299
|
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
242
300
|
lines.push(hrBot);
|
|
@@ -247,11 +305,17 @@ export class TranscriptOverlay implements Component {
|
|
|
247
305
|
// fallow-ignore-next-line unused-class-member
|
|
248
306
|
invalidate(): void {
|
|
249
307
|
this.content.invalidate();
|
|
308
|
+
this.linesDirty = true;
|
|
309
|
+
this.renderedWidth = -1;
|
|
250
310
|
}
|
|
251
311
|
|
|
252
312
|
// fallow-ignore-next-line unused-class-member
|
|
253
313
|
dispose(): void {
|
|
254
314
|
this.closed = true;
|
|
315
|
+
if (this.rebuildTimer) {
|
|
316
|
+
clearTimeout(this.rebuildTimer);
|
|
317
|
+
this.rebuildTimer = undefined;
|
|
318
|
+
}
|
|
255
319
|
if (this.unsubscribe) {
|
|
256
320
|
this.unsubscribe();
|
|
257
321
|
this.unsubscribe = undefined;
|
|
@@ -269,6 +333,50 @@ export class TranscriptOverlay implements Component {
|
|
|
269
333
|
return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
|
|
270
334
|
}
|
|
271
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Coalesce a burst of source-change events into at most one component rebuild
|
|
338
|
+
* per `REBUILD_THROTTLE_MS`. The first event after an idle gap rebuilds
|
|
339
|
+
* immediately (so a freshly-picked agent paints without delay); subsequent
|
|
340
|
+
* events inside the gap are merged into a single trailing rebuild.
|
|
341
|
+
*/
|
|
342
|
+
private scheduleRebuild(): void {
|
|
343
|
+
if (this.closed) return;
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
const elapsed = now - this.lastRebuildAt;
|
|
346
|
+
if (elapsed >= REBUILD_THROTTLE_MS) {
|
|
347
|
+
this.doRebuild();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (this.rebuildTimer) return; // a trailing rebuild is already pending
|
|
351
|
+
this.rebuildTimer = setTimeout(() => {
|
|
352
|
+
this.rebuildTimer = undefined;
|
|
353
|
+
this.doRebuild();
|
|
354
|
+
}, REBUILD_THROTTLE_MS - elapsed);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Rebuild the component tree, invalidate the line cache, and request a paint. */
|
|
358
|
+
private doRebuild(): void {
|
|
359
|
+
if (this.closed) return;
|
|
360
|
+
this.lastRebuildAt = Date.now();
|
|
361
|
+
this.content = this.rebuild();
|
|
362
|
+
this.linesDirty = true;
|
|
363
|
+
this.tui.requestRender();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Return the laid-out content lines at `innerW`, recomputing the cache only
|
|
368
|
+
* when the component tree changed (`linesDirty`) or the width changed.
|
|
369
|
+
* Cheap O(1) on the hot path (every render frame and every keystroke).
|
|
370
|
+
*/
|
|
371
|
+
private getRenderedLines(innerW: number): string[] {
|
|
372
|
+
if (innerW <= 0) return [];
|
|
373
|
+
if (!this.linesDirty && this.renderedWidth === innerW) return this.renderedLines;
|
|
374
|
+
this.renderedLines = this.buildContentLines(innerW);
|
|
375
|
+
this.renderedWidth = innerW;
|
|
376
|
+
this.linesDirty = false;
|
|
377
|
+
return this.renderedLines;
|
|
378
|
+
}
|
|
379
|
+
|
|
272
380
|
private buildContentLines(innerW: number): string[] {
|
|
273
381
|
if (innerW <= 0) return [];
|
|
274
382
|
const lines = this.content.render(innerW);
|
|
@@ -42,6 +42,8 @@ export interface WidgetAgent {
|
|
|
42
42
|
readonly responseText: string;
|
|
43
43
|
/** Context-window utilisation (0–100), or null when unavailable. */
|
|
44
44
|
readonly contextPercent: number | null;
|
|
45
|
+
/** Short display model name (always shown; falls back to parent model when unset). */
|
|
46
|
+
readonly modelName?: string;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
// ── Per-agent rendering ──────────────────────────────────────────────────────
|
|
@@ -74,6 +76,7 @@ export function renderFinishedLine(agent: WidgetAgent, registry: AgentConfigLook
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
const parts: string[] = [];
|
|
79
|
+
if (agent.modelName) parts.push(agent.modelName);
|
|
77
80
|
parts.push(formatTurns(agent.turnCount, agent.maxTurns));
|
|
78
81
|
if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
|
|
79
82
|
parts.push(duration);
|
|
@@ -98,6 +101,7 @@ export function renderRunningLines(
|
|
|
98
101
|
const tokenText = tokens > 0 ? formatSessionTokens(tokens, agent.contextPercent, theme, agent.compactionCount) : "";
|
|
99
102
|
|
|
100
103
|
const parts: string[] = [];
|
|
104
|
+
if (agent.modelName) parts.push(agent.modelName);
|
|
101
105
|
parts.push(formatTurns(agent.turnCount, agent.maxTurns));
|
|
102
106
|
if (agent.toolUses > 0) parts.push(`${agent.toolUses} tool use${agent.toolUses === 1 ? "" : "s"}`);
|
|
103
107
|
if (tokenText) parts.push(tokenText);
|
|
@@ -155,14 +159,14 @@ function buildSections(
|
|
|
155
159
|
): WidgetSections {
|
|
156
160
|
const finishedLines: string[] = [];
|
|
157
161
|
for (const a of categories.finished) {
|
|
158
|
-
finishedLines.push(truncate(theme.fg("dim", "\u251C\u2500")
|
|
162
|
+
finishedLines.push(truncate(`${theme.fg("dim", "\u251C\u2500")} ${renderFinishedLine(a, registry, theme)}`));
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
const runningLines: [string, string][] = [];
|
|
162
166
|
for (const a of categories.running) {
|
|
163
167
|
const [header, act] = renderRunningLines(a, registry, spinnerFrame, theme);
|
|
164
168
|
runningLines.push([
|
|
165
|
-
truncate(theme.fg("dim", "\u251C\u2500")
|
|
169
|
+
truncate(`${theme.fg("dim", "\u251C\u2500")} ${header}`),
|
|
166
170
|
truncate(theme.fg("dim", "\u2502 ") + act),
|
|
167
171
|
]);
|
|
168
172
|
}
|
|
@@ -248,7 +252,7 @@ function assembleOverflow(
|
|
|
248
252
|
const overflowText = overflowParts.join(", ");
|
|
249
253
|
lines.push(
|
|
250
254
|
truncate(
|
|
251
|
-
theme.fg("dim", "\u2514\u2500")
|
|
255
|
+
`${theme.fg("dim", "\u2514\u2500")} ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`,
|
|
252
256
|
),
|
|
253
257
|
);
|
|
254
258
|
return lines;
|
|
@@ -287,7 +291,7 @@ export function renderWidgetLines(params: {
|
|
|
287
291
|
// Assemble with overflow cap (heading takes 1 line).
|
|
288
292
|
const maxBody = MAX_WIDGET_LINES - 1;
|
|
289
293
|
const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
|
|
290
|
-
const heading = truncate(theme.fg(headingColor, headingIcon)
|
|
294
|
+
const heading = truncate(`${theme.fg(headingColor, headingIcon)} ${theme.fg(headingColor, "Agents")}`);
|
|
291
295
|
|
|
292
296
|
if (totalBody <= maxBody) {
|
|
293
297
|
return assembleWithinBudget(heading, { finishedLines, runningLines, queuedLine });
|