@gotgenes/pi-subagents 17.1.0 → 17.3.0
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/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/docs/architecture/architecture.md +22 -7
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +34 -0
- package/docs/plans/0444-shrink-widget-to-background-agents.md +156 -0
- package/docs/plans/0445-native-session-navigation.md +365 -0
- package/docs/retro/0444-shrink-widget-to-background-agents.md +81 -0
- package/docs/retro/0445-native-session-navigation.md +57 -0
- package/docs/retro/0447-extract-subagents-settings-command.md +45 -0
- package/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/lifecycle/subagent-session.ts +6 -0
- package/src/lifecycle/subagent.ts +6 -1
- package/src/types.ts +10 -1
- package/src/ui/agent-widget.ts +18 -8
- package/src/ui/session-navigation.ts +114 -0
- package/src/ui/session-navigator.ts +237 -0
|
@@ -20,7 +20,7 @@ import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
|
|
|
20
20
|
import { NotificationState } from "#src/observation/notification-state";
|
|
21
21
|
import { subscribeSubagentObserver } from "#src/observation/record-observer";
|
|
22
22
|
import type { RunConfig } from "#src/runtime";
|
|
23
|
-
import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
|
|
23
|
+
import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SessionMessage, SubagentType, ThinkingLevel } from "#src/types";
|
|
24
24
|
|
|
25
25
|
/** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
|
|
26
26
|
export interface SubagentLifecycleObserver {
|
|
@@ -162,6 +162,11 @@ export class Subagent {
|
|
|
162
162
|
return this.subagentSession?.messages ?? [];
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
|
|
166
|
+
get agentMessages(): readonly SessionMessage[] {
|
|
167
|
+
return this.subagentSession?.agentMessages ?? [];
|
|
168
|
+
}
|
|
169
|
+
|
|
165
170
|
constructor(init: SubagentInit) {
|
|
166
171
|
// Identity
|
|
167
172
|
this.id = init.id;
|
package/src/types.ts
CHANGED
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
-
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { AgentSessionEvent, SessionContext as SdkSessionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type { ModelRegistry } from "#src/session/model-resolver";
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
export { Subagent } from "#src/lifecycle/subagent";
|
|
11
11
|
export type { AgentSessionEvent, ThinkingLevel };
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* One message in a child session's history, typed from Pi's `SessionContext`.
|
|
15
|
+
*
|
|
16
|
+
* Derived from the barrel-exported `SessionContext` (whose `messages` field is
|
|
17
|
+
* `AgentMessage[]`) so the package needs no direct dependency on
|
|
18
|
+
* `@earendil-works/pi-agent-core`, which is not re-exported from the public barrel.
|
|
19
|
+
*/
|
|
20
|
+
export type SessionMessage = SdkSessionContext["messages"][number];
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* Narrow session interface for event subscription.
|
|
15
24
|
* Used by record-observer — only the subscribe method is needed.
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -148,6 +148,16 @@ export class AgentWidget implements SubagentManagerObserver {
|
|
|
148
148
|
return age < maxAge;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Background agents only — the widget's sole audience (ADR-0004 Decision A).
|
|
153
|
+
* Foreground runs are rendered by the `subagent` tool's inline `onUpdate` stream,
|
|
154
|
+
* so funneling both `listAgents()` call sites through this accessor applies the
|
|
155
|
+
* background predicate exactly once at the source.
|
|
156
|
+
*/
|
|
157
|
+
private listBackgroundAgents(): Subagent[] {
|
|
158
|
+
return this.manager.listAgents().filter(record => record.invocation?.runInBackground === true);
|
|
159
|
+
}
|
|
160
|
+
|
|
151
161
|
/** Project a live Subagent record onto a pure-data WidgetAgent snapshot. */
|
|
152
162
|
private toWidgetAgent(record: Subagent): WidgetAgent {
|
|
153
163
|
return {
|
|
@@ -172,7 +182,7 @@ export class AgentWidget implements SubagentManagerObserver {
|
|
|
172
182
|
/** Delegate rendering to the pure widget-renderer module. */
|
|
173
183
|
private renderWidget(tui: any, theme: Theme): string[] {
|
|
174
184
|
return renderWidgetLines({
|
|
175
|
-
agents: this.
|
|
185
|
+
agents: this.listBackgroundAgents().map(r => this.toWidgetAgent(r)),
|
|
176
186
|
registry: this.registry,
|
|
177
187
|
spinnerFrame: this.widgetFrame,
|
|
178
188
|
terminalWidth: tui.terminal.columns,
|
|
@@ -183,10 +193,10 @@ export class AgentWidget implements SubagentManagerObserver {
|
|
|
183
193
|
|
|
184
194
|
/**
|
|
185
195
|
* Unregister the widget, clear the status bar, stop the interval timer, and
|
|
186
|
-
* purge stale `finishedTurnAge` entries for agents no longer in `
|
|
196
|
+
* purge stale `finishedTurnAge` entries for agents no longer in `backgroundAgents`.
|
|
187
197
|
* Called only from `update`'s idle path — not from `dispose`.
|
|
188
198
|
*/
|
|
189
|
-
private clearWidget(
|
|
199
|
+
private clearWidget(backgroundAgents: readonly AgentSummary[]): void {
|
|
190
200
|
if (this.widgetRegistered) {
|
|
191
201
|
this.uiCtx!.setWidget("agents", undefined);
|
|
192
202
|
this.widgetRegistered = false;
|
|
@@ -198,7 +208,7 @@ export class AgentWidget implements SubagentManagerObserver {
|
|
|
198
208
|
}
|
|
199
209
|
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
|
|
200
210
|
for (const [id] of this.finishedTurnAge) {
|
|
201
|
-
if (!
|
|
211
|
+
if (!backgroundAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
|
|
202
212
|
}
|
|
203
213
|
}
|
|
204
214
|
|
|
@@ -240,12 +250,12 @@ export class AgentWidget implements SubagentManagerObserver {
|
|
|
240
250
|
update() {
|
|
241
251
|
if (!this.uiCtx) return;
|
|
242
252
|
|
|
243
|
-
const
|
|
244
|
-
this.seedFinishedAgents(
|
|
245
|
-
const state = assembleWidgetState(
|
|
253
|
+
const backgroundAgents = this.listBackgroundAgents();
|
|
254
|
+
this.seedFinishedAgents(backgroundAgents);
|
|
255
|
+
const state = assembleWidgetState(backgroundAgents, (id, status) => this.shouldShowFinished(id, status));
|
|
246
256
|
|
|
247
257
|
if (!state.hasActive && !state.hasFinished) {
|
|
248
|
-
this.clearWidget(
|
|
258
|
+
this.clearWidget(backgroundAgents);
|
|
249
259
|
return;
|
|
250
260
|
}
|
|
251
261
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-navigation.ts — Pure selection and transcript-sourcing for native session navigation.
|
|
3
|
+
*
|
|
4
|
+
* Splits the unit-testable core of the `/subagent-sessions` command from its TUI
|
|
5
|
+
* wiring (`session-navigator.ts`): which subagents are navigable, how a picked
|
|
6
|
+
* agent's transcript is sourced (live, in this slice), and how the transcript
|
|
7
|
+
* renders to plain text via Pi's own `serializeConversation`.
|
|
8
|
+
*
|
|
9
|
+
* The `TranscriptSource` seam decouples *how messages are sourced* (live record
|
|
10
|
+
* here; a file snapshot in a follow-up) from *how they render* (text here; Pi's
|
|
11
|
+
* per-entry components in a follow-up). The renderer talks only to this seam.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { serializeConversation } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
16
|
+
import type { SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
17
|
+
import type { AgentSessionEvent, SessionMessage, SubagentType } from "#src/types";
|
|
18
|
+
import { describeActivity, formatDuration, getDisplayName } from "#src/ui/display";
|
|
19
|
+
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** The record fields the navigator reads to label and live-source a transcript. */
|
|
23
|
+
export interface NavigableSubagent {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly type: SubagentType;
|
|
26
|
+
readonly description: string;
|
|
27
|
+
readonly status: SubagentStatus;
|
|
28
|
+
readonly startedAt: number;
|
|
29
|
+
readonly completedAt: number | undefined;
|
|
30
|
+
readonly toolUses: number;
|
|
31
|
+
readonly activeTools: ReadonlyMap<string, string>;
|
|
32
|
+
readonly responseText: string;
|
|
33
|
+
readonly agentMessages: readonly SessionMessage[];
|
|
34
|
+
isSessionReady(): boolean;
|
|
35
|
+
subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A navigable entry: a record plus the label shown in the picker. */
|
|
39
|
+
export interface NavigationEntry {
|
|
40
|
+
readonly record: NavigableSubagent;
|
|
41
|
+
readonly label: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Running-agent streaming state, surfaced by a live source. */
|
|
45
|
+
export interface StreamingState {
|
|
46
|
+
readonly activeTools: ReadonlyMap<string, string>;
|
|
47
|
+
readonly responseText: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Liveness-agnostic transcript source consumed by the renderer. */
|
|
51
|
+
export interface TranscriptSource {
|
|
52
|
+
/** Current message history. */
|
|
53
|
+
getMessages(): readonly SessionMessage[];
|
|
54
|
+
/** Subscribe to changes; returns an unsubscribe, or undefined for a static snapshot. */
|
|
55
|
+
subscribe(onChange: () => void): (() => void) | undefined;
|
|
56
|
+
/** Running-agent streaming state, or undefined when not streaming. */
|
|
57
|
+
streaming(): StreamingState | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Filter the agents to those with a viewable session and label each for the picker. */
|
|
61
|
+
export function listNavigableAgents(
|
|
62
|
+
agents: readonly NavigableSubagent[],
|
|
63
|
+
registry: AgentConfigLookup,
|
|
64
|
+
): NavigationEntry[] {
|
|
65
|
+
return agents
|
|
66
|
+
.filter((record) => record.isSessionReady())
|
|
67
|
+
.map((record) => ({ record, label: buildLabel(record, registry) }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Source a transcript live from an in-memory record (this slice's only source). */
|
|
71
|
+
export function liveSource(record: NavigableSubagent): TranscriptSource {
|
|
72
|
+
return {
|
|
73
|
+
getMessages: () => record.agentMessages,
|
|
74
|
+
subscribe: (onChange) => record.subscribeToUpdates(() => onChange()),
|
|
75
|
+
streaming: () =>
|
|
76
|
+
record.status === "running"
|
|
77
|
+
? { activeTools: record.activeTools, responseText: record.responseText }
|
|
78
|
+
: undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Render a source's transcript to plain text lines via Pi's `serializeConversation`. */
|
|
83
|
+
export function renderTranscriptLines(source: TranscriptSource): string[] {
|
|
84
|
+
const messages = source.getMessages();
|
|
85
|
+
const lines =
|
|
86
|
+
messages.length === 0 ? ["(no messages yet)"] : serializeConversation(toMessages(messages)).split("\n");
|
|
87
|
+
|
|
88
|
+
const streaming = source.streaming();
|
|
89
|
+
if (streaming) {
|
|
90
|
+
lines.push("", `◍ ${describeActivity(streaming.activeTools, streaming.responseText)}`);
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Bridge the session's `AgentMessage[]` to `serializeConversation`'s `Message[]`.
|
|
97
|
+
*
|
|
98
|
+
* `AgentMessage` is a superset of `Message` (it adds session-display variants such
|
|
99
|
+
* as `BashExecutionMessage`); `serializeConversation` renders the shared shape and
|
|
100
|
+
* best-effort text for the rest. `Message` is not re-exported from the public
|
|
101
|
+
* `@earendil-works/pi-ai` barrel, so the parameter type is referenced via the
|
|
102
|
+
* function signature rather than imported by name.
|
|
103
|
+
*/
|
|
104
|
+
function toMessages(
|
|
105
|
+
messages: readonly SessionMessage[],
|
|
106
|
+
): Parameters<typeof serializeConversation>[0] {
|
|
107
|
+
return messages as unknown as Parameters<typeof serializeConversation>[0];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildLabel(record: NavigableSubagent, registry: AgentConfigLookup): string {
|
|
111
|
+
const name = getDisplayName(record.type, registry);
|
|
112
|
+
const duration = formatDuration(record.startedAt, record.completedAt);
|
|
113
|
+
return `${name} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${duration}`;
|
|
114
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-navigator.ts — The `/subagent-sessions` command: pick a subagent and
|
|
3
|
+
* read its transcript through Pi's own session-rendering text.
|
|
4
|
+
*
|
|
5
|
+
* SDK/TUI consumer half of native session navigation. The unit-testable core
|
|
6
|
+
* (selection, sourcing, text rendering) lives in `session-navigation.ts`; this
|
|
7
|
+
* module wires that core to the command picker and a read-only scrollable overlay.
|
|
8
|
+
*
|
|
9
|
+
* The overlay is strictly read-only — steering stays in the `steer_subagent` tool
|
|
10
|
+
* and the widget. It consumes a `TranscriptSource`, so the renderer-upgrade and
|
|
11
|
+
* evicted-agent-source follow-ups swap the source/renderer without touching it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
15
|
+
import type { AgentConfigLookup } from "#src/config/agent-types";
|
|
16
|
+
import type { Theme } from "#src/ui/display";
|
|
17
|
+
import {
|
|
18
|
+
listNavigableAgents,
|
|
19
|
+
liveSource,
|
|
20
|
+
type NavigableSubagent,
|
|
21
|
+
renderTranscriptLines,
|
|
22
|
+
type TranscriptSource,
|
|
23
|
+
} from "#src/ui/session-navigation";
|
|
24
|
+
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Chrome lines: top border + header + header sep + footer sep + footer + bottom border. */
|
|
28
|
+
const CHROME_LINES = 6;
|
|
29
|
+
const MIN_VIEWPORT = 3;
|
|
30
|
+
const VIEWPORT_HEIGHT_PCT = 70;
|
|
31
|
+
|
|
32
|
+
/** Component factory shape Pi's `ui.custom` invokes to mount an overlay. */
|
|
33
|
+
export type OverlayComponentFactory<R> = (
|
|
34
|
+
tui: TUI,
|
|
35
|
+
theme: Theme,
|
|
36
|
+
keybindings: unknown,
|
|
37
|
+
done: (result: R) => void,
|
|
38
|
+
) => Component;
|
|
39
|
+
|
|
40
|
+
/** Narrow UI interface — only the `ctx.ui` methods the navigator calls. */
|
|
41
|
+
export interface SessionNavigatorUI {
|
|
42
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
43
|
+
notify(message: string, level: "info" | "warning" | "error"): void;
|
|
44
|
+
custom<R>(component: OverlayComponentFactory<R>, options?: unknown): Promise<R>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parameters for one `/subagent-sessions` invocation. */
|
|
48
|
+
export interface SessionNavigatorParams {
|
|
49
|
+
ui: SessionNavigatorUI;
|
|
50
|
+
agents: readonly NavigableSubagent[];
|
|
51
|
+
registry: AgentConfigLookup;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Options for the read-only transcript overlay. */
|
|
55
|
+
export interface TranscriptOverlayOptions {
|
|
56
|
+
tui: TUI;
|
|
57
|
+
theme: Theme;
|
|
58
|
+
source: TranscriptSource;
|
|
59
|
+
done: (result: undefined) => void;
|
|
60
|
+
wrapText: (text: string, width: number) => string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handler for the `/subagent-sessions` slash command.
|
|
65
|
+
*
|
|
66
|
+
* Lists navigable subagents, lets the operator pick one, and opens its transcript
|
|
67
|
+
* read-only. Receives the agent snapshot (`manager.listAgents()`) rather than the
|
|
68
|
+
* manager, so it stays a reactive consumer with no inbound call into the core.
|
|
69
|
+
*/
|
|
70
|
+
export class SessionNavigatorHandler {
|
|
71
|
+
async handle({ ui, agents, registry }: SessionNavigatorParams): Promise<void> {
|
|
72
|
+
const entries = listNavigableAgents(agents, registry);
|
|
73
|
+
if (entries.length === 0) {
|
|
74
|
+
ui.notify("No subagent sessions to view.", "info");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const choice = await ui.select(
|
|
79
|
+
"Subagent sessions",
|
|
80
|
+
entries.map((entry) => entry.label),
|
|
81
|
+
);
|
|
82
|
+
const entry = entries.find((candidate) => candidate.label === choice);
|
|
83
|
+
if (!entry) return;
|
|
84
|
+
|
|
85
|
+
const source = liveSource(entry.record);
|
|
86
|
+
await ui.custom<undefined>(
|
|
87
|
+
(tui, theme, _keybindings, done) =>
|
|
88
|
+
new TranscriptOverlay({ tui, theme, source, done, wrapText: wrapTextWithAnsi }),
|
|
89
|
+
{
|
|
90
|
+
overlay: true,
|
|
91
|
+
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Read-only scrollable transcript overlay.
|
|
99
|
+
*
|
|
100
|
+
* Re-renders on every source update (live agents); the transcript text is Pi's
|
|
101
|
+
* `serializeConversation` output from `renderTranscriptLines` — this class owns
|
|
102
|
+
* only scroll state and chrome, no message formatting.
|
|
103
|
+
*/
|
|
104
|
+
export class TranscriptOverlay implements Component {
|
|
105
|
+
private scrollOffset = 0;
|
|
106
|
+
private autoScroll = true;
|
|
107
|
+
private unsubscribe: (() => void) | undefined;
|
|
108
|
+
private closed = false;
|
|
109
|
+
|
|
110
|
+
private readonly tui: TUI;
|
|
111
|
+
private readonly theme: Theme;
|
|
112
|
+
private readonly source: TranscriptSource;
|
|
113
|
+
private readonly done: (result: undefined) => void;
|
|
114
|
+
private readonly wrapText: (text: string, width: number) => string[];
|
|
115
|
+
|
|
116
|
+
constructor({ tui, theme, source, done, wrapText }: TranscriptOverlayOptions) {
|
|
117
|
+
this.tui = tui;
|
|
118
|
+
this.theme = theme;
|
|
119
|
+
this.source = source;
|
|
120
|
+
this.done = done;
|
|
121
|
+
this.wrapText = wrapText;
|
|
122
|
+
this.unsubscribe = source.subscribe(() => {
|
|
123
|
+
if (this.closed) return;
|
|
124
|
+
this.tui.requestRender();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// fallow-ignore-next-line unused-class-member
|
|
129
|
+
handleInput(data: string): void {
|
|
130
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
131
|
+
this.closed = true;
|
|
132
|
+
this.done(undefined);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const totalLines = this.buildContentLines(this.innerWidth()).length;
|
|
137
|
+
const viewportHeight = this.viewportHeight();
|
|
138
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
139
|
+
|
|
140
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
141
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
142
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
143
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
144
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
145
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
146
|
+
} else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
|
|
147
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
148
|
+
this.autoScroll = false;
|
|
149
|
+
} else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
|
|
150
|
+
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
151
|
+
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
152
|
+
} else if (matchesKey(data, "home")) {
|
|
153
|
+
this.scrollOffset = 0;
|
|
154
|
+
this.autoScroll = false;
|
|
155
|
+
} else if (matchesKey(data, "end")) {
|
|
156
|
+
this.scrollOffset = maxScroll;
|
|
157
|
+
this.autoScroll = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
render(width: number): string[] {
|
|
162
|
+
if (width < 6) return [];
|
|
163
|
+
const th = this.theme;
|
|
164
|
+
const innerW = width - 4;
|
|
165
|
+
const lines: string[] = [];
|
|
166
|
+
|
|
167
|
+
const pad = (s: string, len: number): string => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
|
|
168
|
+
const row = (content: string): string =>
|
|
169
|
+
th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW) + " " + th.fg("border", "│");
|
|
170
|
+
const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
|
|
171
|
+
const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
|
|
172
|
+
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
|
|
173
|
+
|
|
174
|
+
lines.push(hrTop);
|
|
175
|
+
lines.push(row(th.bold("Subagent session")));
|
|
176
|
+
lines.push(hrMid);
|
|
177
|
+
|
|
178
|
+
const contentLines = this.buildContentLines(innerW);
|
|
179
|
+
const viewportHeight = this.viewportHeight();
|
|
180
|
+
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
181
|
+
if (this.autoScroll) this.scrollOffset = maxScroll;
|
|
182
|
+
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
183
|
+
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
|
|
184
|
+
for (let i = 0; i < viewportHeight; i++) lines.push(row(visible[i] ?? ""));
|
|
185
|
+
|
|
186
|
+
lines.push(hrMid);
|
|
187
|
+
const scrollPct =
|
|
188
|
+
contentLines.length <= viewportHeight
|
|
189
|
+
? "100%"
|
|
190
|
+
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
|
|
191
|
+
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
|
|
192
|
+
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
|
|
193
|
+
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
|
|
194
|
+
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
|
|
195
|
+
lines.push(hrBot);
|
|
196
|
+
|
|
197
|
+
return lines;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// fallow-ignore-next-line unused-class-member
|
|
201
|
+
invalidate(): void {
|
|
202
|
+
/* no cached state to clear */
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// fallow-ignore-next-line unused-class-member
|
|
206
|
+
dispose(): void {
|
|
207
|
+
this.closed = true;
|
|
208
|
+
if (this.unsubscribe) {
|
|
209
|
+
this.unsubscribe();
|
|
210
|
+
this.unsubscribe = undefined;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- Private ----
|
|
215
|
+
|
|
216
|
+
private innerWidth(): number {
|
|
217
|
+
return Math.max(0, this.tui.terminal.columns - 4);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private viewportHeight(): number {
|
|
221
|
+
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
|
|
222
|
+
return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private buildContentLines(innerW: number): string[] {
|
|
226
|
+
if (innerW <= 0) return [];
|
|
227
|
+
const wrapped: string[] = [];
|
|
228
|
+
for (const line of renderTranscriptLines(this.source)) {
|
|
229
|
+
if (line === "") {
|
|
230
|
+
wrapped.push("");
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
wrapped.push(...this.wrapText(line, innerW));
|
|
234
|
+
}
|
|
235
|
+
return wrapped.map((l) => truncateToWidth(l, innerW));
|
|
236
|
+
}
|
|
237
|
+
}
|