@ryan_nookpi/pi-extension-subagent 0.1.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/LICENSE +21 -0
- package/README.md +242 -0
- package/above-widget.ts +20 -0
- package/agents.ts +233 -0
- package/claude-args.ts +103 -0
- package/claude-sdk-runner.ts +223 -0
- package/claude-sdk-stream-adapter.ts +49 -0
- package/claude-sidecar-writer.ts +107 -0
- package/claude-stream-parser.ts +400 -0
- package/cli.ts +549 -0
- package/commands.ts +2295 -0
- package/config.ts +119 -0
- package/constants.ts +115 -0
- package/context-limits.ts +132 -0
- package/display-task.ts +98 -0
- package/escalation.ts +159 -0
- package/format.ts +188 -0
- package/group-pending.ts +90 -0
- package/index.ts +200 -0
- package/invocation-queue.ts +29 -0
- package/live-preview.ts +39 -0
- package/package.json +95 -0
- package/persisted-session.ts +185 -0
- package/replay.ts +461 -0
- package/retry.ts +164 -0
- package/run-utils.ts +203 -0
- package/runner.ts +979 -0
- package/session.ts +319 -0
- package/store.ts +143 -0
- package/tool-execute.ts +1932 -0
- package/tool-render.ts +145 -0
- package/types.ts +193 -0
- package/utils/agent-utils.ts +315 -0
- package/utils/format-utils.ts +255 -0
- package/utils/short-label.ts +78 -0
- package/utils/string-utils.ts +229 -0
- package/utils/time-utils.ts +105 -0
- package/widget.ts +294 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** biome-ignore-all lint/suspicious/noExplicitAny: persisted JSONL entries are dynamic runtime data. */
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
5
|
+
export interface CompletionMarker {
|
|
6
|
+
exitCode: number;
|
|
7
|
+
stopReason?: string;
|
|
8
|
+
runtime?: string;
|
|
9
|
+
timestamp?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PersistedSessionReadOptions {
|
|
13
|
+
startOffset?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PersistedSessionSnapshot {
|
|
17
|
+
messages: Message[];
|
|
18
|
+
latestActivityAt?: number;
|
|
19
|
+
finalOutput: string;
|
|
20
|
+
terminalStopReason?: string;
|
|
21
|
+
completionMarker?: CompletionMarker;
|
|
22
|
+
isTerminal: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DisplayTaskUpdateEntry {
|
|
26
|
+
runId: number;
|
|
27
|
+
task: string;
|
|
28
|
+
displayTask: string;
|
|
29
|
+
startedAt: number;
|
|
30
|
+
updatedAt?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseTimestamp(raw: unknown): number | undefined {
|
|
34
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
35
|
+
if (typeof raw === "string") {
|
|
36
|
+
const parsed = Date.parse(raw);
|
|
37
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractFinalOutput(messages: Message[]): string {
|
|
43
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
44
|
+
const msg = messages[i];
|
|
45
|
+
if (msg.role !== "assistant") continue;
|
|
46
|
+
for (const part of msg.content) {
|
|
47
|
+
if (part.type === "text" && part.text) return part.text;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
51
|
+
const msg = messages[i];
|
|
52
|
+
if (msg.role !== "assistant") continue;
|
|
53
|
+
for (const part of msg.content) {
|
|
54
|
+
if (part.type === "thinking" && part.thinking) return part.thinking;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getSessionFileMtimeMs(sessionFile?: string): number | undefined {
|
|
61
|
+
if (!sessionFile) return undefined;
|
|
62
|
+
try {
|
|
63
|
+
return fs.statSync(sessionFile).mtimeMs;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getSessionFileSize(sessionFile?: string): number {
|
|
70
|
+
if (!sessionFile) return 0;
|
|
71
|
+
try {
|
|
72
|
+
return fs.statSync(sessionFile).size;
|
|
73
|
+
} catch {
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function appendCompletionMarker(sessionFile: string | undefined, marker: CompletionMarker): void {
|
|
79
|
+
if (!sessionFile) return;
|
|
80
|
+
const entry = {
|
|
81
|
+
type: "subagent_done",
|
|
82
|
+
timestamp: marker.timestamp ?? Date.now(),
|
|
83
|
+
exitCode: marker.exitCode,
|
|
84
|
+
stopReason: marker.stopReason,
|
|
85
|
+
runtime: marker.runtime,
|
|
86
|
+
};
|
|
87
|
+
try {
|
|
88
|
+
fs.appendFileSync(sessionFile, `${JSON.stringify(entry)}\n`, "utf8");
|
|
89
|
+
} catch {
|
|
90
|
+
// best effort only
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function appendDisplayTaskUpdate(sessionFile: string | undefined, update: DisplayTaskUpdateEntry): void {
|
|
95
|
+
if (!sessionFile) return;
|
|
96
|
+
const timestamp = new Date(update.updatedAt ?? Date.now()).toISOString();
|
|
97
|
+
const entry = {
|
|
98
|
+
type: "custom",
|
|
99
|
+
customType: "subagent-display-task",
|
|
100
|
+
data: {
|
|
101
|
+
runId: update.runId,
|
|
102
|
+
task: update.task,
|
|
103
|
+
displayTask: update.displayTask,
|
|
104
|
+
startedAt: update.startedAt,
|
|
105
|
+
updatedAt: update.updatedAt ?? Date.now(),
|
|
106
|
+
},
|
|
107
|
+
id: `subagent-display-task-${update.runId}-${Math.random().toString(16).slice(2, 10)}`,
|
|
108
|
+
timestamp,
|
|
109
|
+
};
|
|
110
|
+
try {
|
|
111
|
+
fs.appendFileSync(sessionFile, `${JSON.stringify(entry)}\n`, "utf8");
|
|
112
|
+
} catch {
|
|
113
|
+
// best effort only
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readPersistedSessionSnapshot(
|
|
118
|
+
sessionFile?: string,
|
|
119
|
+
options: PersistedSessionReadOptions = {},
|
|
120
|
+
): PersistedSessionSnapshot {
|
|
121
|
+
if (!sessionFile || !fs.existsSync(sessionFile)) {
|
|
122
|
+
return { messages: [], finalOutput: "", isTerminal: false };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let raw = "";
|
|
126
|
+
try {
|
|
127
|
+
const buffer = fs.readFileSync(sessionFile);
|
|
128
|
+
const startOffset = Math.max(0, Math.min(options.startOffset ?? 0, buffer.length));
|
|
129
|
+
raw = buffer.subarray(startOffset).toString("utf8");
|
|
130
|
+
} catch {
|
|
131
|
+
return { messages: [], finalOutput: "", isTerminal: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const messages: Message[] = [];
|
|
135
|
+
let latestActivityAt: number | undefined;
|
|
136
|
+
let terminalStopReason: string | undefined;
|
|
137
|
+
let completionMarker: CompletionMarker | undefined;
|
|
138
|
+
|
|
139
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
140
|
+
if (!line.trim()) continue;
|
|
141
|
+
|
|
142
|
+
let entry: any;
|
|
143
|
+
try {
|
|
144
|
+
entry = JSON.parse(line);
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (entry?.type === "subagent_done") {
|
|
150
|
+
const markerTs = parseTimestamp(entry.timestamp);
|
|
151
|
+
if (markerTs && (latestActivityAt == null || markerTs > latestActivityAt)) latestActivityAt = markerTs;
|
|
152
|
+
completionMarker = {
|
|
153
|
+
exitCode: typeof entry.exitCode === "number" ? entry.exitCode : 0,
|
|
154
|
+
stopReason: typeof entry.stopReason === "string" ? entry.stopReason : undefined,
|
|
155
|
+
runtime: typeof entry.runtime === "string" ? entry.runtime : undefined,
|
|
156
|
+
timestamp: markerTs,
|
|
157
|
+
};
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (entry?.type !== "message" || !entry.message) continue;
|
|
162
|
+
const message = entry.message as Message & { stopReason?: string; timestamp?: string | number };
|
|
163
|
+
const role = (message as any).role;
|
|
164
|
+
if (role !== "user" && role !== "assistant" && role !== "toolResult") continue;
|
|
165
|
+
messages.push(message);
|
|
166
|
+
|
|
167
|
+
const ts = parseTimestamp((message as any).timestamp ?? entry.timestamp);
|
|
168
|
+
if (ts && (latestActivityAt == null || ts > latestActivityAt)) latestActivityAt = ts;
|
|
169
|
+
|
|
170
|
+
if (role === "assistant") {
|
|
171
|
+
const stopReason = typeof (message as any).stopReason === "string" ? (message as any).stopReason : undefined;
|
|
172
|
+
if (stopReason && stopReason !== "toolUse") terminalStopReason = stopReason;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const finalOutput = extractFinalOutput(messages);
|
|
177
|
+
return {
|
|
178
|
+
messages,
|
|
179
|
+
latestActivityAt,
|
|
180
|
+
finalOutput,
|
|
181
|
+
terminalStopReason,
|
|
182
|
+
completionMarker,
|
|
183
|
+
isTerminal: !!completionMarker || !!terminalStopReason,
|
|
184
|
+
};
|
|
185
|
+
}
|
package/replay.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/** biome-ignore-all lint/suspicious/noExplicitAny: replay data is read from loosely typed session JSONL and TUI callbacks. */
|
|
2
|
+
/**
|
|
3
|
+
* Session replay viewer — reads session JSONL files and renders an
|
|
4
|
+
* interactive TUI overlay for browsing past subagent conversations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import {
|
|
9
|
+
Container,
|
|
10
|
+
Key,
|
|
11
|
+
matchesKey,
|
|
12
|
+
Spacer,
|
|
13
|
+
Text,
|
|
14
|
+
truncateToWidth,
|
|
15
|
+
visibleWidth,
|
|
16
|
+
wrapTextWithAnsi,
|
|
17
|
+
} from "@earendil-works/pi-tui";
|
|
18
|
+
import {
|
|
19
|
+
DEFAULT_TURN_COUNT,
|
|
20
|
+
DETAIL_LINE_PADDING,
|
|
21
|
+
DETAIL_PAGE_DIVISOR,
|
|
22
|
+
DETAIL_SECTION_RESERVED_ROWS,
|
|
23
|
+
DETAIL_WIDTH_PADDING,
|
|
24
|
+
ELLIPSIS_RESERVED_CHARS,
|
|
25
|
+
FALLBACK_TERMINAL_ROWS,
|
|
26
|
+
JSON_SUMMARY_MAX_CHARS,
|
|
27
|
+
LIST_HEIGHT_RATIO,
|
|
28
|
+
LIST_PAGE_DIVISOR,
|
|
29
|
+
MAX_LIST_ROWS,
|
|
30
|
+
MIN_BODY_ROWS,
|
|
31
|
+
MIN_DETAIL_BODY_ROWS,
|
|
32
|
+
MIN_DETAIL_WIDTH,
|
|
33
|
+
MIN_INNER_WIDTH,
|
|
34
|
+
MIN_LIST_ROWS,
|
|
35
|
+
MIN_PAGE_SIZE,
|
|
36
|
+
MIN_SEPARATOR_WIDTH,
|
|
37
|
+
MIN_TASK_WIDTH,
|
|
38
|
+
MIN_TERMINAL_ROWS,
|
|
39
|
+
OVERLAY_HORIZONTAL_MARGIN,
|
|
40
|
+
REPLAY_CONTENT_MAX_CHARS,
|
|
41
|
+
RESERVED_LAYOUT_ROWS,
|
|
42
|
+
TASK_WIDTH_PADDING,
|
|
43
|
+
TOOL_CALL_ARGS_SUMMARY_MAX_CHARS,
|
|
44
|
+
TOOL_RESULT_DETAILS_SUMMARY_MAX_CHARS,
|
|
45
|
+
USAGE_EXTRA_ROWS,
|
|
46
|
+
} from "./constants.js";
|
|
47
|
+
import { formatUsageStats, truncatePlainToWidth } from "./format.js";
|
|
48
|
+
import type { CommandRunState, SessionReplayItem } from "./types.js";
|
|
49
|
+
import { formatDuration, formatDurationBetween } from "./utils/time-utils.js";
|
|
50
|
+
|
|
51
|
+
// ─── Replay Helpers ──────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function truncateSingleLine(value: string, max: number): string {
|
|
54
|
+
if (value.length <= max) return value;
|
|
55
|
+
if (max <= ELLIPSIS_RESERVED_CHARS) return value.slice(0, max);
|
|
56
|
+
return `${value.slice(0, max - ELLIPSIS_RESERVED_CHARS)}...`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatReplayTime(date: Date): string {
|
|
60
|
+
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseDateSafely(raw: unknown): Date {
|
|
64
|
+
if (typeof raw === "number") {
|
|
65
|
+
const d = new Date(raw);
|
|
66
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
67
|
+
}
|
|
68
|
+
if (typeof raw === "string") {
|
|
69
|
+
const d = new Date(raw);
|
|
70
|
+
if (!Number.isNaN(d.getTime())) return d;
|
|
71
|
+
}
|
|
72
|
+
return new Date();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function summarizeJson(value: unknown, max = JSON_SUMMARY_MAX_CHARS): string {
|
|
76
|
+
if (value === undefined || value === null) return "";
|
|
77
|
+
let text = "";
|
|
78
|
+
try {
|
|
79
|
+
text = JSON.stringify(value);
|
|
80
|
+
} catch {
|
|
81
|
+
text = String(value);
|
|
82
|
+
}
|
|
83
|
+
if (!text || text === "{}") return "";
|
|
84
|
+
return truncateSingleLine(text, max);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractReplayContent(content: any): string {
|
|
88
|
+
if (typeof content === "string") return content;
|
|
89
|
+
if (!Array.isArray(content)) return "";
|
|
90
|
+
|
|
91
|
+
const parts: string[] = [];
|
|
92
|
+
for (const part of content) {
|
|
93
|
+
if (!part || typeof part !== "object") continue;
|
|
94
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
95
|
+
parts.push(part.text);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (part.type === "thinking") {
|
|
99
|
+
const thinking = typeof part.thinking === "string" ? part.thinking : "";
|
|
100
|
+
if (thinking.trim()) {
|
|
101
|
+
parts.push(`💭 ${thinking}`);
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (part.type === "toolCall") {
|
|
106
|
+
const name = typeof part.name === "string" ? part.name : "tool";
|
|
107
|
+
const args = summarizeJson(part.arguments, TOOL_CALL_ARGS_SUMMARY_MAX_CHARS);
|
|
108
|
+
parts.push(args ? `→ ${name} ${args}` : `→ ${name}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return parts.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Max characters kept per replay item content to avoid memory blowup. */
|
|
116
|
+
function truncateReplayContent(text: string, max = REPLAY_CONTENT_MAX_CHARS): string {
|
|
117
|
+
if (text.length <= max) return text;
|
|
118
|
+
return `${text.slice(0, max)}\n… [truncated ${text.length - max} chars]`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
export function readSessionReplayItems(sessionFile: string): SessionReplayItem[] {
|
|
124
|
+
if (!sessionFile || !fs.existsSync(sessionFile)) return [];
|
|
125
|
+
|
|
126
|
+
let raw = "";
|
|
127
|
+
try {
|
|
128
|
+
raw = fs.readFileSync(sessionFile, "utf-8");
|
|
129
|
+
} catch {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
if (!raw.trim()) return [];
|
|
133
|
+
|
|
134
|
+
const items: SessionReplayItem[] = [];
|
|
135
|
+
let prevTime: Date | null = null;
|
|
136
|
+
|
|
137
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
138
|
+
if (!line.trim()) continue;
|
|
139
|
+
let entry: any;
|
|
140
|
+
try {
|
|
141
|
+
entry = JSON.parse(line);
|
|
142
|
+
} catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (entry?.type !== "message" || !entry.message) continue;
|
|
147
|
+
const msg = entry.message;
|
|
148
|
+
const ts = parseDateSafely(msg.timestamp ?? entry.timestamp);
|
|
149
|
+
const elapsed = prevTime ? formatDurationBetween(prevTime, ts) : undefined;
|
|
150
|
+
prevTime = ts;
|
|
151
|
+
|
|
152
|
+
if (msg.role === "user") {
|
|
153
|
+
const content = truncateReplayContent(extractReplayContent(msg.content).trim());
|
|
154
|
+
if (!content) continue;
|
|
155
|
+
items.push({ type: "user", title: "User", content, timestamp: ts, elapsed });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (msg.role === "assistant") {
|
|
160
|
+
const content = truncateReplayContent(extractReplayContent(msg.content).trim());
|
|
161
|
+
if (!content) continue;
|
|
162
|
+
items.push({ type: "assistant", title: "Assistant", content, timestamp: ts, elapsed });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (msg.role === "toolResult") {
|
|
167
|
+
const toolName = typeof msg.toolName === "string" ? msg.toolName : "tool";
|
|
168
|
+
let content = extractReplayContent(msg.content).trim();
|
|
169
|
+
if (!content && msg.details !== undefined) {
|
|
170
|
+
const detailPreview = summarizeJson(msg.details, TOOL_RESULT_DETAILS_SUMMARY_MAX_CHARS);
|
|
171
|
+
if (detailPreview) content = `details: ${detailPreview}`;
|
|
172
|
+
}
|
|
173
|
+
if (!content) content = "(no output)";
|
|
174
|
+
items.push({
|
|
175
|
+
type: "tool",
|
|
176
|
+
title: `Tool: ${toolName}`,
|
|
177
|
+
content: truncateReplayContent(content),
|
|
178
|
+
timestamp: ts,
|
|
179
|
+
elapsed,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return items;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export class SubagentSessionReplayOverlay {
|
|
188
|
+
private selectedIndex = 0;
|
|
189
|
+
private expandedIndex: number | null = null;
|
|
190
|
+
private listScrollOffset = 0;
|
|
191
|
+
private detailScrollOffset = 0;
|
|
192
|
+
private cachedDetailWidth = -1;
|
|
193
|
+
private detailWrapCache = new Map<number, string[]>();
|
|
194
|
+
|
|
195
|
+
constructor(
|
|
196
|
+
private run: CommandRunState,
|
|
197
|
+
private items: SessionReplayItem[],
|
|
198
|
+
private onDone: () => void,
|
|
199
|
+
) {
|
|
200
|
+
this.selectedIndex = Math.max(0, items.length - 1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private getTerminalRows(): number {
|
|
204
|
+
return Math.max(MIN_TERMINAL_ROWS, (process.stdout as any).rows || FALLBACK_TERMINAL_ROWS);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private getViewportSizes(hasDetail: boolean, hasUsage: boolean): { list: number; detail: number } {
|
|
208
|
+
const rows = this.getTerminalRows();
|
|
209
|
+
const reserved = RESERVED_LAYOUT_ROWS + (hasUsage ? USAGE_EXTRA_ROWS : 0); // top/header/task/sep/footer/help/bottom
|
|
210
|
+
const body = Math.max(MIN_BODY_ROWS, rows - reserved);
|
|
211
|
+
if (!hasDetail) return { list: Math.max(MIN_LIST_ROWS, body), detail: 0 };
|
|
212
|
+
|
|
213
|
+
const detailBody = Math.max(MIN_DETAIL_BODY_ROWS, body - DETAIL_SECTION_RESERVED_ROWS); // detail separator + detail header
|
|
214
|
+
const list = Math.max(MIN_LIST_ROWS, Math.min(MAX_LIST_ROWS, Math.floor(detailBody * LIST_HEIGHT_RATIO)));
|
|
215
|
+
const detail = Math.max(MIN_LIST_ROWS, detailBody - list);
|
|
216
|
+
return { list, detail };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private onSelectionMoved(): void {
|
|
220
|
+
if (this.expandedIndex !== null) {
|
|
221
|
+
this.expandedIndex = this.selectedIndex;
|
|
222
|
+
this.detailScrollOffset = 0;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private ensureListVisible(listViewport: number): void {
|
|
227
|
+
if (this.selectedIndex < this.listScrollOffset) {
|
|
228
|
+
this.listScrollOffset = this.selectedIndex;
|
|
229
|
+
} else if (this.selectedIndex >= this.listScrollOffset + listViewport) {
|
|
230
|
+
this.listScrollOffset = this.selectedIndex - listViewport + 1;
|
|
231
|
+
}
|
|
232
|
+
const maxOffset = Math.max(0, this.items.length - listViewport);
|
|
233
|
+
if (this.listScrollOffset > maxOffset) this.listScrollOffset = maxOffset;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private getDetailLines(itemIndex: number, contentWidth: number): string[] {
|
|
237
|
+
if (this.cachedDetailWidth !== contentWidth) {
|
|
238
|
+
this.cachedDetailWidth = contentWidth;
|
|
239
|
+
this.detailWrapCache.clear();
|
|
240
|
+
}
|
|
241
|
+
const cached = this.detailWrapCache.get(itemIndex);
|
|
242
|
+
if (cached) return cached;
|
|
243
|
+
|
|
244
|
+
const raw = this.items[itemIndex]?.content ?? "";
|
|
245
|
+
const normalized = raw.replace(/\r/g, "");
|
|
246
|
+
const lines: string[] = [];
|
|
247
|
+
for (const sourceLine of normalized.split("\n")) {
|
|
248
|
+
if (!sourceLine) {
|
|
249
|
+
lines.push("");
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const targetWidth = Math.max(MIN_DETAIL_WIDTH, contentWidth);
|
|
253
|
+
const wrapped = wrapTextWithAnsi(sourceLine, targetWidth);
|
|
254
|
+
if (wrapped.length === 0) {
|
|
255
|
+
lines.push(sourceLine);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (wrapped.length === 1 && wrapped[0].length > targetWidth) {
|
|
259
|
+
for (let i = 0; i < sourceLine.length; i += targetWidth) {
|
|
260
|
+
lines.push(sourceLine.slice(i, i + targetWidth));
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
lines.push(...wrapped);
|
|
265
|
+
}
|
|
266
|
+
if (lines.length === 0) lines.push("(empty)");
|
|
267
|
+
|
|
268
|
+
this.detailWrapCache.set(itemIndex, lines);
|
|
269
|
+
return lines;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private scrollDetail(delta: number): void {
|
|
273
|
+
if (this.expandedIndex === null) return;
|
|
274
|
+
this.detailScrollOffset = Math.max(0, this.detailScrollOffset + delta);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
handleInput(data: string, tui: any): void {
|
|
278
|
+
const listPage = Math.max(MIN_PAGE_SIZE, Math.floor(this.getTerminalRows() / LIST_PAGE_DIVISOR));
|
|
279
|
+
const detailPage = Math.max(MIN_LIST_ROWS, Math.floor(this.getTerminalRows() / DETAIL_PAGE_DIVISOR));
|
|
280
|
+
const hasDetailOpen = this.expandedIndex !== null;
|
|
281
|
+
|
|
282
|
+
if ((matchesKey(data, Key.left) || data === "h") && hasDetailOpen) {
|
|
283
|
+
this.scrollDetail(-1);
|
|
284
|
+
} else if ((matchesKey(data, Key.right) || data === "l") && hasDetailOpen) {
|
|
285
|
+
this.scrollDetail(1);
|
|
286
|
+
} else if (matchesKey(data, Key.ctrl("u")) && hasDetailOpen) {
|
|
287
|
+
this.scrollDetail(-detailPage);
|
|
288
|
+
} else if (matchesKey(data, Key.ctrl("d")) && hasDetailOpen) {
|
|
289
|
+
this.scrollDetail(detailPage);
|
|
290
|
+
} else if (matchesKey(data, Key.up) || data === "k") {
|
|
291
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
292
|
+
this.onSelectionMoved();
|
|
293
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
294
|
+
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
|
|
295
|
+
this.onSelectionMoved();
|
|
296
|
+
} else if (data === "\x1b[5~" /* PageUp */) {
|
|
297
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - listPage);
|
|
298
|
+
this.onSelectionMoved();
|
|
299
|
+
} else if (data === "\x1b[6~" /* PageDown */) {
|
|
300
|
+
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + listPage);
|
|
301
|
+
this.onSelectionMoved();
|
|
302
|
+
} else if (data === "g") {
|
|
303
|
+
this.selectedIndex = 0;
|
|
304
|
+
this.onSelectionMoved();
|
|
305
|
+
} else if (data === "G") {
|
|
306
|
+
this.selectedIndex = Math.max(0, this.items.length - 1);
|
|
307
|
+
this.onSelectionMoved();
|
|
308
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
309
|
+
if (this.expandedIndex === this.selectedIndex) {
|
|
310
|
+
this.expandedIndex = null;
|
|
311
|
+
this.detailScrollOffset = 0;
|
|
312
|
+
} else {
|
|
313
|
+
this.expandedIndex = this.selectedIndex;
|
|
314
|
+
this.detailScrollOffset = 0;
|
|
315
|
+
}
|
|
316
|
+
} else if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
|
|
317
|
+
this.onDone();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
tui.requestRender();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
render(width: number, _height: number, theme: any): string[] {
|
|
324
|
+
const container = new Container();
|
|
325
|
+
const pad = " ";
|
|
326
|
+
const innerWidth = Math.max(MIN_INNER_WIDTH, width - OVERLAY_HORIZONTAL_MARGIN);
|
|
327
|
+
const elapsedLabel = formatDuration(this.run.elapsedMs);
|
|
328
|
+
const usageLine = this.run.usage ? formatUsageStats(this.run.usage, this.run.model) : "";
|
|
329
|
+
const task = this.run.task
|
|
330
|
+
.replace(/\s*\n+\s*/g, " ")
|
|
331
|
+
.replace(/\s{2,}/g, " ")
|
|
332
|
+
.trim();
|
|
333
|
+
const hasDetailOpen = this.expandedIndex !== null;
|
|
334
|
+
const { list: listViewport, detail: detailViewport } = this.getViewportSizes(hasDetailOpen, Boolean(usageLine));
|
|
335
|
+
|
|
336
|
+
this.ensureListVisible(listViewport);
|
|
337
|
+
|
|
338
|
+
const contextLabel = this.run.contextMode === "main" ? "main" : "isolated";
|
|
339
|
+
const formatFooterLine = (helpText: string, rangeText: string) => {
|
|
340
|
+
const rangeWidth = visibleWidth(rangeText);
|
|
341
|
+
const gap = rangeWidth > 0 ? 2 : 0;
|
|
342
|
+
const helpWidth = Math.max(0, innerWidth - rangeWidth - gap);
|
|
343
|
+
const help = truncatePlainToWidth(helpText, helpWidth);
|
|
344
|
+
return `${theme.fg("dim", help)}${" ".repeat(gap)}${theme.fg("accent", rangeText)}`;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
container.addChild(new Spacer(1));
|
|
348
|
+
container.addChild(
|
|
349
|
+
new Text(
|
|
350
|
+
pad +
|
|
351
|
+
theme.fg("toolTitle", theme.bold(`#${this.run.id} ${this.run.agent}`)) +
|
|
352
|
+
theme.fg(
|
|
353
|
+
"dim",
|
|
354
|
+
` [${this.run.status}] ctx:${contextLabel} turn:${this.run.turnCount ?? DEFAULT_TURN_COUNT} ${elapsedLabel} tools:${this.run.toolCalls}`,
|
|
355
|
+
),
|
|
356
|
+
0,
|
|
357
|
+
0,
|
|
358
|
+
),
|
|
359
|
+
);
|
|
360
|
+
container.addChild(
|
|
361
|
+
new Text(
|
|
362
|
+
pad + theme.fg("dim", truncateSingleLine(task, Math.max(MIN_TASK_WIDTH, innerWidth - TASK_WIDTH_PADDING))),
|
|
363
|
+
0,
|
|
364
|
+
0,
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
if (usageLine) container.addChild(new Text(pad + theme.fg("dim", usageLine), 0, 0));
|
|
368
|
+
container.addChild(new Text(pad + theme.fg("muted", "─".repeat(Math.max(MIN_SEPARATOR_WIDTH, innerWidth))), 0, 0));
|
|
369
|
+
|
|
370
|
+
for (let row = 0; row < listViewport; row++) {
|
|
371
|
+
const idx = this.listScrollOffset + row;
|
|
372
|
+
const item = this.items[idx];
|
|
373
|
+
if (!item) {
|
|
374
|
+
container.addChild(new Text("", 0, 0));
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const isSelected = idx === this.selectedIndex;
|
|
379
|
+
let icon = "○";
|
|
380
|
+
let color: "success" | "accent" | "warning" | "dim" = "dim";
|
|
381
|
+
if (item.type === "user") {
|
|
382
|
+
icon = "👤";
|
|
383
|
+
color = "success";
|
|
384
|
+
} else if (item.type === "assistant") {
|
|
385
|
+
icon = "🤖";
|
|
386
|
+
color = "accent";
|
|
387
|
+
} else if (item.type === "tool") {
|
|
388
|
+
icon = "🛠️";
|
|
389
|
+
color = "warning";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const timeLabel = `[${formatReplayTime(item.timestamp)}${item.elapsed ? ` +${item.elapsed}` : ""}]`;
|
|
393
|
+
const marker = isSelected ? "▸" : " ";
|
|
394
|
+
const preview =
|
|
395
|
+
item.content
|
|
396
|
+
.replace(/\s*\n+\s*/g, " ")
|
|
397
|
+
.replace(/\s{2,}/g, " ")
|
|
398
|
+
.trim() || "(empty)";
|
|
399
|
+
const fixed = `${marker} ${theme.fg(color, icon)} ${theme.bold(item.title)} ${theme.fg("dim", timeLabel)} `;
|
|
400
|
+
const previewWidth = Math.max(0, innerWidth - visibleWidth(fixed));
|
|
401
|
+
let line = `${fixed}${theme.fg("muted", truncatePlainToWidth(preview, previewWidth))}`;
|
|
402
|
+
line = truncateToWidth(line, innerWidth, "");
|
|
403
|
+
if (isSelected) line = theme.bg("selectedBg", line);
|
|
404
|
+
container.addChild(new Text(pad + line, 0, 0));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (hasDetailOpen) {
|
|
408
|
+
container.addChild(
|
|
409
|
+
new Text(pad + theme.fg("muted", "─".repeat(Math.max(MIN_SEPARATOR_WIDTH, innerWidth))), 0, 0),
|
|
410
|
+
);
|
|
411
|
+
const detailIndex = this.expandedIndex ?? this.selectedIndex;
|
|
412
|
+
const detailItem = this.items[detailIndex];
|
|
413
|
+
const detailLines = this.getDetailLines(
|
|
414
|
+
detailIndex,
|
|
415
|
+
Math.max(MIN_DETAIL_WIDTH, innerWidth - DETAIL_WIDTH_PADDING),
|
|
416
|
+
);
|
|
417
|
+
const maxDetailOffset = Math.max(0, detailLines.length - detailViewport);
|
|
418
|
+
if (this.detailScrollOffset > maxDetailOffset) this.detailScrollOffset = maxDetailOffset;
|
|
419
|
+
const start = this.detailScrollOffset;
|
|
420
|
+
const end = Math.min(detailLines.length, start + detailViewport);
|
|
421
|
+
const range =
|
|
422
|
+
detailLines.length === 0 ? "0-0/0" : `${start + 1}-${Math.max(start + 1, end)}/${detailLines.length}`;
|
|
423
|
+
container.addChild(
|
|
424
|
+
new Text(
|
|
425
|
+
pad +
|
|
426
|
+
theme.fg("accent", theme.bold(`Detail: ${detailItem?.title ?? "entry"}`)) +
|
|
427
|
+
theme.fg("dim", ` (${range})`),
|
|
428
|
+
0,
|
|
429
|
+
0,
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
for (let i = start; i < end; i++) {
|
|
434
|
+
const line = detailLines[i] ?? "";
|
|
435
|
+
container.addChild(
|
|
436
|
+
new Text(
|
|
437
|
+
pad +
|
|
438
|
+
theme.fg(
|
|
439
|
+
"toolOutput",
|
|
440
|
+
` ${truncatePlainToWidth(line, Math.max(MIN_DETAIL_WIDTH, innerWidth - DETAIL_LINE_PADDING))}`,
|
|
441
|
+
),
|
|
442
|
+
0,
|
|
443
|
+
0,
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
container.addChild(new Text(pad + theme.fg("muted", "─".repeat(Math.max(MIN_SEPARATOR_WIDTH, innerWidth))), 0, 0));
|
|
450
|
+
const listStart = this.items.length === 0 ? 0 : this.listScrollOffset + 1;
|
|
451
|
+
const listEnd = Math.min(this.items.length, this.listScrollOffset + listViewport);
|
|
452
|
+
const listRange = `${listStart}-${listEnd}/${this.items.length}`;
|
|
453
|
+
const helpText = hasDetailOpen
|
|
454
|
+
? "↑↓/jk list · Enter close detail · ←/→ or h/l detail scroll · Ctrl+u/d detail page · PgUp/Dn list · Esc close"
|
|
455
|
+
: "↑↓/jk navigate · Enter open detail · PgUp/Dn page · g/G top/end · Esc close";
|
|
456
|
+
container.addChild(new Text(pad + formatFooterLine(helpText, listRange), 0, 0));
|
|
457
|
+
container.addChild(new Spacer(1));
|
|
458
|
+
|
|
459
|
+
return container.render(width);
|
|
460
|
+
}
|
|
461
|
+
}
|