pi-agent-extensions 0.4.3 → 0.4.4
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/extensions/sessions/index.ts +409 -8
- package/extensions/sessions/sessions.ts +174 -0
- package/package.json +1 -1
|
@@ -9,18 +9,27 @@ import {
|
|
|
9
9
|
Spacer,
|
|
10
10
|
Text,
|
|
11
11
|
matchesKey,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
visibleWidth,
|
|
14
|
+
wrapTextWithAnsi,
|
|
12
15
|
} from "@mariozechner/pi-tui";
|
|
13
16
|
import {
|
|
17
|
+
buildPreviewError,
|
|
14
18
|
buildSessionDescription,
|
|
15
19
|
buildSessionLabel,
|
|
20
|
+
buildSessionPreview,
|
|
16
21
|
buildSessionSearchEntries,
|
|
17
22
|
filterSessionEntries,
|
|
23
|
+
getSessionPaneLayout,
|
|
18
24
|
parseLimit,
|
|
25
|
+
type PreviewBlock,
|
|
19
26
|
type SessionInfoLike,
|
|
27
|
+
type SessionPreview,
|
|
20
28
|
} from "./sessions.js";
|
|
21
29
|
|
|
22
|
-
const DEFAULT_VISIBLE =
|
|
30
|
+
const DEFAULT_VISIBLE = 12;
|
|
23
31
|
const SNIPPET_MAX = 60;
|
|
32
|
+
const PREVIEW_LOAD_DEBOUNCE_MS = 50;
|
|
24
33
|
|
|
25
34
|
const isPrintable = (data: string): boolean => {
|
|
26
35
|
if (data.length !== 1) return false;
|
|
@@ -37,6 +46,212 @@ const formatPlainLine = (session: SessionInfoLike): string => {
|
|
|
37
46
|
return `${label}\t${description}`;
|
|
38
47
|
};
|
|
39
48
|
|
|
49
|
+
const padAnsiRight = (text: string, width: number): string => {
|
|
50
|
+
const truncated = truncateToWidth(text, width, "");
|
|
51
|
+
return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const themeText = (
|
|
55
|
+
theme: { fg: (color: any, text: string) => string; bold: (text: string) => string; italic?: (text: string) => string },
|
|
56
|
+
kind: "title" | "subtitle" | "rule" | "user" | "assistant" | "tool" | "error" | "muted" | "text" | "thinking",
|
|
57
|
+
text: string,
|
|
58
|
+
): string => {
|
|
59
|
+
if (kind === "title") return theme.fg("accent", theme.bold(text));
|
|
60
|
+
if (kind === "subtitle") return theme.fg("dim", text);
|
|
61
|
+
if (kind === "rule") return theme.fg("border", text);
|
|
62
|
+
if (kind === "user") return theme.fg("accent", theme.bold(text));
|
|
63
|
+
if (kind === "assistant") return theme.fg("warning", theme.bold(text));
|
|
64
|
+
if (kind === "tool") return theme.fg("muted", theme.bold(text));
|
|
65
|
+
if (kind === "error") return theme.fg("error", text);
|
|
66
|
+
if (kind === "muted") return theme.fg("muted", text);
|
|
67
|
+
if (kind === "thinking") return theme.italic ? theme.italic(theme.fg("dim", text)) : theme.fg("dim", text);
|
|
68
|
+
return theme.fg("text", text);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
interface PreviewRenderOptions {
|
|
72
|
+
toolsExpanded: boolean;
|
|
73
|
+
thinkingVisible: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const splitLines = (text: string): string[] => text.replace(/\r\n/g, "\n").split("\n");
|
|
77
|
+
|
|
78
|
+
const compactText = (text: string, maxLines: number): { lines: string[]; hidden: number } => {
|
|
79
|
+
const lines = splitLines(text).map((line) => line.replace(/\s+$/g, "")).filter((line) => line.trim().length > 0);
|
|
80
|
+
if (lines.length <= maxLines) return { lines, hidden: 0 };
|
|
81
|
+
return { lines: lines.slice(0, maxLines), hidden: lines.length - maxLines };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const wrapStyled = (
|
|
85
|
+
prefix: string,
|
|
86
|
+
text: string,
|
|
87
|
+
width: number,
|
|
88
|
+
color: (line: string) => string,
|
|
89
|
+
): string[] => {
|
|
90
|
+
const contentWidth = Math.max(1, width - visibleWidth(prefix));
|
|
91
|
+
const wrapped = wrapTextWithAnsi(color(text), contentWidth);
|
|
92
|
+
return wrapped.map((line) => `${prefix}${line}`);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const renderTextBlock = (
|
|
96
|
+
label: string,
|
|
97
|
+
text: string,
|
|
98
|
+
width: number,
|
|
99
|
+
theme: { fg: (color: any, text: string) => string; bold: (text: string) => string },
|
|
100
|
+
labelKind: "user" | "assistant" | "tool" | "muted" | "error" | "thinking",
|
|
101
|
+
): string[] => {
|
|
102
|
+
const lines = [themeText(theme, labelKind, label)];
|
|
103
|
+
for (const rawLine of splitLines(text)) {
|
|
104
|
+
if (!rawLine.trim()) {
|
|
105
|
+
lines.push("");
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
lines.push(...wrapStyled(" ", rawLine.trimEnd(), width, (line) => theme.fg("text", line)));
|
|
109
|
+
}
|
|
110
|
+
return lines;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const isToolBlock = (block: PreviewBlock): boolean =>
|
|
114
|
+
block.kind === "toolCall" || block.kind === "toolResult" || block.kind === "bash";
|
|
115
|
+
|
|
116
|
+
const summarizeToolRun = (blocks: PreviewBlock[]): string => {
|
|
117
|
+
const names = new Set<string>();
|
|
118
|
+
let outputLines = 0;
|
|
119
|
+
let hasError = false;
|
|
120
|
+
|
|
121
|
+
for (const block of blocks) {
|
|
122
|
+
if (block.kind === "toolCall") names.add(block.name);
|
|
123
|
+
if (block.kind === "toolResult") {
|
|
124
|
+
if (block.name) names.add(block.name);
|
|
125
|
+
outputLines += compactText(block.text, Number.MAX_SAFE_INTEGER).lines.length;
|
|
126
|
+
hasError ||= !!block.isError;
|
|
127
|
+
}
|
|
128
|
+
if (block.kind === "bash") {
|
|
129
|
+
names.add("bash");
|
|
130
|
+
outputLines += compactText(block.output ?? "", Number.MAX_SAFE_INTEGER).lines.length;
|
|
131
|
+
hasError ||= !!block.isError;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const nameList = Array.from(names).slice(0, 4).join(", ");
|
|
136
|
+
const moreNames = names.size > 4 ? ` +${names.size - 4}` : "";
|
|
137
|
+
const output = outputLines > 0 ? ` · ${outputLines} output lines` : "";
|
|
138
|
+
return `${hasError ? "✖" : "▸"} Tool activity · ${blocks.length} events${nameList ? ` · ${nameList}${moreNames}` : ""}${output} (press t)`;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const renderPreviewBlock = (
|
|
142
|
+
block: PreviewBlock,
|
|
143
|
+
width: number,
|
|
144
|
+
theme: { fg: (color: any, text: string) => string; bold: (text: string) => string; italic?: (text: string) => string },
|
|
145
|
+
options: PreviewRenderOptions,
|
|
146
|
+
): string[] => {
|
|
147
|
+
if (block.kind === "notice") {
|
|
148
|
+
return wrapTextWithAnsi(themeText(theme, "muted", block.text), width);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (block.kind === "user") {
|
|
152
|
+
return renderTextBlock("◆ User", block.text, width, theme, "user");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (block.kind === "assistant") {
|
|
156
|
+
return renderTextBlock("● Assistant", block.text, width, theme, "assistant");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (block.kind === "thinking") {
|
|
160
|
+
if (!options.thinkingVisible) return [themeText(theme, "thinking", "◌ Thinking hidden")];
|
|
161
|
+
return renderTextBlock("◌ Thinking", block.text, width, theme, "thinking");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (block.kind === "toolCall") {
|
|
165
|
+
const header = `▸ Tool call · ${block.name}`;
|
|
166
|
+
if (!options.toolsExpanded || !block.args) return [themeText(theme, "tool", header)];
|
|
167
|
+
return [themeText(theme, "tool", header), ...wrapStyled(" ", block.args, width, (line) => theme.fg("muted", line))];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (block.kind === "toolResult") {
|
|
171
|
+
const label = `${block.isError ? "✖" : "▸"} Tool result${block.name ? ` · ${block.name}` : ""}`;
|
|
172
|
+
const compact = compactText(block.text, options.toolsExpanded ? 200 : 4);
|
|
173
|
+
const lines = [themeText(theme, block.isError ? "error" : "tool", label)];
|
|
174
|
+
for (const line of compact.lines) {
|
|
175
|
+
lines.push(...wrapStyled(" ", line, width, (value) => theme.fg(block.isError ? "error" : "muted", value)));
|
|
176
|
+
}
|
|
177
|
+
if (compact.hidden > 0) lines.push(theme.fg("dim", ` … ${compact.hidden} output lines collapsed (press t to expand tools)`));
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (block.kind === "bash") {
|
|
182
|
+
const label = `${block.isError ? "✖" : "▸"} Bash`;
|
|
183
|
+
const lines = [themeText(theme, block.isError ? "error" : "tool", label)];
|
|
184
|
+
if (block.command) lines.push(...wrapStyled(" ", `$ ${block.command}`, width, (line) => theme.fg("accent", line)));
|
|
185
|
+
const compact = compactText(block.output ?? "", options.toolsExpanded ? 200 : 4);
|
|
186
|
+
for (const line of compact.lines) {
|
|
187
|
+
lines.push(...wrapStyled(" ", line, width, (value) => theme.fg(block.isError ? "error" : "muted", value)));
|
|
188
|
+
}
|
|
189
|
+
if (compact.hidden > 0) lines.push(theme.fg("dim", ` … ${compact.hidden} output lines collapsed (press t to expand tools)`));
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (block.kind === "summary") {
|
|
194
|
+
return renderTextBlock(`◇ ${block.label}`, block.text, width, theme, "muted");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return renderTextBlock(`◇ ${block.label}`, block.text, width, theme, "muted");
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const renderPreview = (
|
|
201
|
+
preview: SessionPreview | undefined,
|
|
202
|
+
width: number,
|
|
203
|
+
height: number,
|
|
204
|
+
scrollOffset: number,
|
|
205
|
+
theme: { fg: (color: any, text: string) => string; bold: (text: string) => string; italic?: (text: string) => string },
|
|
206
|
+
options: PreviewRenderOptions,
|
|
207
|
+
): { lines: string[]; totalLines: number; maxScroll: number } => {
|
|
208
|
+
const raw: string[] = [];
|
|
209
|
+
|
|
210
|
+
if (!preview) {
|
|
211
|
+
raw.push(themeText(theme, "title", "Preview"));
|
|
212
|
+
raw.push(themeText(theme, "subtitle", "Loading selected session…"));
|
|
213
|
+
} else {
|
|
214
|
+
raw.push(themeText(theme, preview.error ? "error" : "title", preview.title));
|
|
215
|
+
raw.push(themeText(theme, "subtitle", preview.subtitle));
|
|
216
|
+
raw.push(themeText(theme, "rule", "─".repeat(Math.max(0, width))));
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < preview.blocks.length; i++) {
|
|
219
|
+
const block = preview.blocks[i]!;
|
|
220
|
+
if (!options.toolsExpanded && isToolBlock(block)) {
|
|
221
|
+
const run: PreviewBlock[] = [];
|
|
222
|
+
while (i < preview.blocks.length && isToolBlock(preview.blocks[i]!)) {
|
|
223
|
+
run.push(preview.blocks[i]!);
|
|
224
|
+
i++;
|
|
225
|
+
}
|
|
226
|
+
i--;
|
|
227
|
+
if (raw.length > 3) raw.push("");
|
|
228
|
+
raw.push(...wrapTextWithAnsi(themeText(theme, "tool", summarizeToolRun(run)), width));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (raw.length > 3) raw.push("");
|
|
233
|
+
raw.push(...renderPreviewBlock(block, width, theme, options));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const maxScroll = Math.max(0, raw.length - height);
|
|
238
|
+
const boundedOffset = Math.max(0, Math.min(scrollOffset, maxScroll));
|
|
239
|
+
const visible = raw.slice(boundedOffset, boundedOffset + height).map((line) => padAnsiRight(line, width));
|
|
240
|
+
while (visible.length < height) visible.push(" ".repeat(width));
|
|
241
|
+
|
|
242
|
+
return { lines: visible, totalLines: raw.length, maxScroll };
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const loadSessionPreview = async (session: SessionInfoLike): Promise<SessionPreview> => {
|
|
246
|
+
try {
|
|
247
|
+
const manager = SessionManager.open(session.path);
|
|
248
|
+
const context = manager.buildSessionContext();
|
|
249
|
+
return buildSessionPreview(session, context.messages as any[]);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return buildPreviewError(session, error);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
40
255
|
async function listSessions(ctx: ExtensionCommandContext): Promise<SessionInfoLike[] | null> {
|
|
41
256
|
if (!ctx.hasUI) {
|
|
42
257
|
const sessions = await SessionManager.list(ctx.cwd);
|
|
@@ -113,10 +328,59 @@ async function showSessionPicker(
|
|
|
113
328
|
const entries = buildSessionSearchEntries(sorted);
|
|
114
329
|
const sessionByPath = new Map(sorted.map((session) => [session.path, session]));
|
|
115
330
|
|
|
116
|
-
return ctx.ui.custom<SessionInfoLike | null>((tui, theme,
|
|
331
|
+
return ctx.ui.custom<SessionInfoLike | null>((tui, theme, kb, done) => {
|
|
117
332
|
let filter = "";
|
|
118
333
|
let selectList: SelectList;
|
|
119
|
-
|
|
334
|
+
let filteredEntries = entries;
|
|
335
|
+
let selectedPath = sorted[0]?.path ?? "";
|
|
336
|
+
let previewScrollOffset = 0;
|
|
337
|
+
let toolsExpanded = false;
|
|
338
|
+
let thinkingVisible = false;
|
|
339
|
+
const previewCache = new Map<string, SessionPreview>();
|
|
340
|
+
let activePreview: SessionPreview | undefined;
|
|
341
|
+
let previewTimer: ReturnType<typeof setTimeout> | undefined;
|
|
342
|
+
let previewSeq = 0;
|
|
343
|
+
|
|
344
|
+
const previewKey = (session: SessionInfoLike): string => `${session.path}:${session.modified.getTime()}`;
|
|
345
|
+
|
|
346
|
+
const schedulePreviewLoad = () => {
|
|
347
|
+
previewScrollOffset = 0;
|
|
348
|
+
const session = sessionByPath.get(selectedPath);
|
|
349
|
+
if (!session) {
|
|
350
|
+
activePreview = undefined;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const key = previewKey(session);
|
|
355
|
+
const cached = previewCache.get(key);
|
|
356
|
+
if (cached) {
|
|
357
|
+
activePreview = cached;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
activePreview = {
|
|
362
|
+
title: buildSessionLabel(session),
|
|
363
|
+
subtitle: `${session.modified.toLocaleString()} · loading preview`,
|
|
364
|
+
blocks: [{ kind: "notice", text: "Loading selected session…" }],
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (previewTimer) clearTimeout(previewTimer);
|
|
368
|
+
const seq = ++previewSeq;
|
|
369
|
+
previewTimer = setTimeout(() => {
|
|
370
|
+
void loadSessionPreview(session).then((preview) => {
|
|
371
|
+
if (seq !== previewSeq || selectedPath !== session.path) return;
|
|
372
|
+
previewCache.set(key, preview);
|
|
373
|
+
activePreview = preview;
|
|
374
|
+
tui.requestRender();
|
|
375
|
+
});
|
|
376
|
+
}, PREVIEW_LOAD_DEBOUNCE_MS);
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const setSelectedPath = (path: string | undefined) => {
|
|
380
|
+
if (!path || path === selectedPath) return;
|
|
381
|
+
selectedPath = path;
|
|
382
|
+
schedulePreviewLoad();
|
|
383
|
+
};
|
|
120
384
|
|
|
121
385
|
const buildItems = (current: typeof entries): SelectItem[] =>
|
|
122
386
|
current.map((entry) => ({
|
|
@@ -126,8 +390,8 @@ async function showSessionPicker(
|
|
|
126
390
|
}));
|
|
127
391
|
|
|
128
392
|
const rebuild = () => {
|
|
129
|
-
|
|
130
|
-
const items = buildItems(
|
|
393
|
+
filteredEntries = filterSessionEntries(entries, filter);
|
|
394
|
+
const items = buildItems(filteredEntries);
|
|
131
395
|
const visible = Math.max(1, Math.min(maxVisible, Math.max(items.length, 1)));
|
|
132
396
|
|
|
133
397
|
selectList = new SelectList(items, visible, {
|
|
@@ -138,32 +402,113 @@ async function showSessionPicker(
|
|
|
138
402
|
noMatch: () => theme.fg("warning", " No matching sessions"),
|
|
139
403
|
});
|
|
140
404
|
|
|
405
|
+
const selectedIndex = filteredEntries.findIndex((entry) => entry.session.path === selectedPath);
|
|
406
|
+
if (selectedIndex >= 0) {
|
|
407
|
+
selectList.setSelectedIndex(selectedIndex);
|
|
408
|
+
} else {
|
|
409
|
+
selectedPath = filteredEntries[0]?.session.path ?? "";
|
|
410
|
+
selectList.setSelectedIndex(0);
|
|
411
|
+
schedulePreviewLoad();
|
|
412
|
+
}
|
|
413
|
+
|
|
141
414
|
selectList.onSelect = (item) => {
|
|
142
415
|
const session = sessionByPath.get(item.value) ?? null;
|
|
143
416
|
done(session);
|
|
144
417
|
};
|
|
145
418
|
selectList.onCancel = () => done(null);
|
|
419
|
+
selectList.onSelectionChange = (item) => setSelectedPath(item.value);
|
|
420
|
+
};
|
|
146
421
|
|
|
422
|
+
const renderSinglePane = (width: number): string[] => {
|
|
423
|
+
const container = new Container();
|
|
147
424
|
const filterLine = filter.length
|
|
148
425
|
? `${theme.fg("muted", "Filter: ")}${theme.fg("text", filter)}`
|
|
149
426
|
: `${theme.fg("muted", "Filter: ")}${theme.fg("dim", "type to filter")}`;
|
|
150
427
|
|
|
151
|
-
container.clear();
|
|
152
428
|
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
153
429
|
container.addChild(new Text(theme.fg("accent", theme.bold("Sessions")), 1, 0));
|
|
154
430
|
container.addChild(new Text(filterLine, 1, 0));
|
|
155
431
|
container.addChild(selectList);
|
|
156
432
|
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter open • esc cancel"), 1, 0));
|
|
157
433
|
container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
|
|
434
|
+
return container.render(width);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const buildTopBorder = (listWidth: number, previewWidth: number): string => {
|
|
438
|
+
const leftTitle = " Sessions ";
|
|
439
|
+
const rightTitle = " Preview ";
|
|
440
|
+
const left = `┌─${leftTitle}${"─".repeat(Math.max(0, listWidth - leftTitle.length - 2))}`;
|
|
441
|
+
const right = `${rightTitle}${"─".repeat(Math.max(0, previewWidth - rightTitle.length - 1))}┐`;
|
|
442
|
+
return `${theme.fg("border", left)}${theme.fg("border", "─┬─")}${theme.fg("border", right)}`;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const buildBottomBorder = (listWidth: number, previewWidth: number, previewStats: string): string => {
|
|
446
|
+
const leftHelp = " ↑↓ list • type filter ";
|
|
447
|
+
const rightHelp = previewStats || " pgup/pgdn preview • esc cancel • enter open ";
|
|
448
|
+
const left = `└─${leftHelp}${"─".repeat(Math.max(0, listWidth - leftHelp.length - 2))}`;
|
|
449
|
+
const right = `${rightHelp}${"─".repeat(Math.max(0, previewWidth - rightHelp.length - 1))}┘`;
|
|
450
|
+
return `${theme.fg("border", left)}${theme.fg("border", "─┴─")}${theme.fg("border", right)}`;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const renderSplitPane = (width: number): string[] => {
|
|
454
|
+
const layout = getSessionPaneLayout(width);
|
|
455
|
+
const termRows = Math.max(12, tui.terminal?.rows ?? 24);
|
|
456
|
+
const contentHeight = Math.max(8, termRows - 2);
|
|
457
|
+
const filterLine = filter.length
|
|
458
|
+
? `${theme.fg("muted", "Filter: ")}${theme.fg("text", filter)}`
|
|
459
|
+
: `${theme.fg("muted", "Filter: ")}${theme.fg("dim", "type to filter")}`;
|
|
460
|
+
const listHeight = Math.max(1, contentHeight - 1);
|
|
461
|
+
|
|
462
|
+
// SelectList height is fixed at creation time; recreate it to use the current full-window height.
|
|
463
|
+
const items = buildItems(filteredEntries);
|
|
464
|
+
selectList = new SelectList(items, Math.max(listHeight, Math.min(maxVisible, Math.max(items.length, 1))), {
|
|
465
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
466
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
467
|
+
description: (text) => theme.fg("muted", text),
|
|
468
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
469
|
+
noMatch: () => theme.fg("warning", " No matching sessions"),
|
|
470
|
+
});
|
|
471
|
+
const selectedIndex = filteredEntries.findIndex((entry) => entry.session.path === selectedPath);
|
|
472
|
+
selectList.setSelectedIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
|
473
|
+
selectList.onSelect = (item) => done(sessionByPath.get(item.value) ?? null);
|
|
474
|
+
selectList.onCancel = () => done(null);
|
|
475
|
+
selectList.onSelectionChange = (item) => setSelectedPath(item.value);
|
|
476
|
+
|
|
477
|
+
const leftLines = [filterLine, ...selectList.render(layout.listWidth)].slice(0, contentHeight);
|
|
478
|
+
while (leftLines.length < contentHeight) leftLines.push("");
|
|
479
|
+
|
|
480
|
+
const renderedPreview = renderPreview(activePreview, layout.previewWidth, contentHeight, previewScrollOffset, theme, {
|
|
481
|
+
toolsExpanded,
|
|
482
|
+
thinkingVisible,
|
|
483
|
+
});
|
|
484
|
+
previewScrollOffset = Math.min(previewScrollOffset, renderedPreview.maxScroll);
|
|
485
|
+
const modeHints = `t ${toolsExpanded ? "compact" : "tools"} • h ${thinkingVisible ? "hide thinking" : "thinking"}`;
|
|
486
|
+
const previewStats = renderedPreview.maxScroll > 0
|
|
487
|
+
? ` ${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines} • pgup/pgdn • ${modeHints} `
|
|
488
|
+
: ` esc/enter • ${modeHints} `;
|
|
489
|
+
|
|
490
|
+
const lines = [buildTopBorder(layout.listWidth, layout.previewWidth)];
|
|
491
|
+
for (let i = 0; i < contentHeight; i++) {
|
|
492
|
+
const left = padAnsiRight(leftLines[i] ?? "", layout.listWidth);
|
|
493
|
+
const right = renderedPreview.lines[i] ?? " ".repeat(layout.previewWidth);
|
|
494
|
+
lines.push(`${left}${theme.fg("border", " │ ")}${padAnsiRight(right, layout.previewWidth)}`);
|
|
495
|
+
}
|
|
496
|
+
lines.push(buildBottomBorder(layout.listWidth, layout.previewWidth, previewStats));
|
|
497
|
+
return lines.map((line) => truncateToWidth(line, width, "", true));
|
|
158
498
|
};
|
|
159
499
|
|
|
160
500
|
rebuild();
|
|
501
|
+
schedulePreviewLoad();
|
|
161
502
|
|
|
162
503
|
return {
|
|
163
|
-
render: (width) =>
|
|
504
|
+
render: (width) => {
|
|
505
|
+
const layout = getSessionPaneLayout(width);
|
|
506
|
+
if (layout.mode === "single") return renderSinglePane(width);
|
|
507
|
+
return renderSplitPane(width);
|
|
508
|
+
},
|
|
164
509
|
invalidate: () => {
|
|
510
|
+
previewCache.clear();
|
|
165
511
|
rebuild();
|
|
166
|
-
container.invalidate();
|
|
167
512
|
},
|
|
168
513
|
handleInput: (data) => {
|
|
169
514
|
if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) {
|
|
@@ -175,6 +520,34 @@ async function showSessionPicker(
|
|
|
175
520
|
return;
|
|
176
521
|
}
|
|
177
522
|
|
|
523
|
+
const currentLayout = getSessionPaneLayout(tui.terminal?.columns ?? 80);
|
|
524
|
+
if (currentLayout.mode === "split") {
|
|
525
|
+
if (data === "t") {
|
|
526
|
+
toolsExpanded = !toolsExpanded;
|
|
527
|
+
previewScrollOffset = 0;
|
|
528
|
+
tui.requestRender();
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (data === "h") {
|
|
532
|
+
thinkingVisible = !thinkingVisible;
|
|
533
|
+
previewScrollOffset = 0;
|
|
534
|
+
tui.requestRender();
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const pageSize = Math.max(4, (tui.terminal?.rows ?? 24) - 4);
|
|
539
|
+
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("d"))) {
|
|
540
|
+
previewScrollOffset += pageSize;
|
|
541
|
+
tui.requestRender();
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
|
|
545
|
+
previewScrollOffset = Math.max(0, previewScrollOffset - pageSize);
|
|
546
|
+
tui.requestRender();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
178
551
|
if (isPrintable(data)) {
|
|
179
552
|
filter += data;
|
|
180
553
|
rebuild();
|
|
@@ -182,10 +555,38 @@ async function showSessionPicker(
|
|
|
182
555
|
return;
|
|
183
556
|
}
|
|
184
557
|
|
|
558
|
+
if (kb.matches(data, "tui.select.pageUp")) {
|
|
559
|
+
const currentIndex = Math.max(0, filteredEntries.findIndex((entry) => entry.session.path === selectedPath));
|
|
560
|
+
const nextIndex = Math.max(0, currentIndex - maxVisible);
|
|
561
|
+
selectList.setSelectedIndex(nextIndex);
|
|
562
|
+
setSelectedPath(filteredEntries[nextIndex]?.session.path);
|
|
563
|
+
tui.requestRender();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (kb.matches(data, "tui.select.pageDown")) {
|
|
568
|
+
const currentIndex = Math.max(0, filteredEntries.findIndex((entry) => entry.session.path === selectedPath));
|
|
569
|
+
const nextIndex = Math.min(filteredEntries.length - 1, currentIndex + maxVisible);
|
|
570
|
+
selectList.setSelectedIndex(nextIndex);
|
|
571
|
+
setSelectedPath(filteredEntries[nextIndex]?.session.path);
|
|
572
|
+
tui.requestRender();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
185
576
|
selectList.handleInput(data);
|
|
186
577
|
tui.requestRender();
|
|
187
578
|
},
|
|
188
579
|
};
|
|
580
|
+
}, {
|
|
581
|
+
overlay: true,
|
|
582
|
+
overlayOptions: {
|
|
583
|
+
anchor: "top-left",
|
|
584
|
+
row: 0,
|
|
585
|
+
col: 0,
|
|
586
|
+
width: "100%",
|
|
587
|
+
maxHeight: "100%",
|
|
588
|
+
margin: 0,
|
|
589
|
+
},
|
|
189
590
|
});
|
|
190
591
|
}
|
|
191
592
|
|
|
@@ -5,6 +5,50 @@ export interface SessionInfoLike {
|
|
|
5
5
|
modified: Date;
|
|
6
6
|
firstMessage: string;
|
|
7
7
|
path: string;
|
|
8
|
+
messageCount?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type PreviewContent =
|
|
12
|
+
| string
|
|
13
|
+
| Array<
|
|
14
|
+
| { type: "text"; text: string }
|
|
15
|
+
| { type: "image"; mimeType?: string }
|
|
16
|
+
| { type: "toolCall"; name: string; arguments?: Record<string, unknown> }
|
|
17
|
+
| { type: "thinking"; thinking?: string; redacted?: boolean }
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
export interface PreviewMessageLike {
|
|
21
|
+
role: string;
|
|
22
|
+
content?: PreviewContent;
|
|
23
|
+
toolName?: string;
|
|
24
|
+
isError?: boolean;
|
|
25
|
+
command?: string;
|
|
26
|
+
output?: string;
|
|
27
|
+
summary?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PreviewBlock =
|
|
31
|
+
| { kind: "notice"; text: string }
|
|
32
|
+
| { kind: "user"; text: string }
|
|
33
|
+
| { kind: "assistant"; text: string }
|
|
34
|
+
| { kind: "thinking"; text: string; redacted?: boolean }
|
|
35
|
+
| { kind: "toolCall"; name: string; args?: string }
|
|
36
|
+
| { kind: "toolResult"; name?: string; text: string; isError?: boolean }
|
|
37
|
+
| { kind: "bash"; command: string; output?: string; isError?: boolean }
|
|
38
|
+
| { kind: "summary"; label: string; text: string }
|
|
39
|
+
| { kind: "custom"; label: string; text: string };
|
|
40
|
+
|
|
41
|
+
export interface SessionPreview {
|
|
42
|
+
title: string;
|
|
43
|
+
subtitle: string;
|
|
44
|
+
blocks: PreviewBlock[];
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SessionPaneLayout {
|
|
49
|
+
mode: "single" | "split";
|
|
50
|
+
listWidth: number;
|
|
51
|
+
previewWidth: number;
|
|
8
52
|
}
|
|
9
53
|
|
|
10
54
|
export function parseLimit(args: string | undefined, defaultLimit = 5): number {
|
|
@@ -72,3 +116,133 @@ export function filterSessionEntries(entries: SessionSearchEntry[], filter: stri
|
|
|
72
116
|
export function filterSessionInfos(sessions: SessionInfoLike[], filter: string): SessionInfoLike[] {
|
|
73
117
|
return filterSessionEntries(buildSessionSearchEntries(sessions), filter).map((entry) => entry.session);
|
|
74
118
|
}
|
|
119
|
+
|
|
120
|
+
export function getSessionPaneLayout(width: number): SessionPaneLayout {
|
|
121
|
+
if (width < 80) {
|
|
122
|
+
return { mode: "single", listWidth: width, previewWidth: 0 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const dividerWidth = 3;
|
|
126
|
+
const available = Math.max(0, width - dividerWidth);
|
|
127
|
+
let listRatio = 0.36;
|
|
128
|
+
if (width < 110) {
|
|
129
|
+
listRatio = 0.42;
|
|
130
|
+
} else if (width >= 180) {
|
|
131
|
+
listRatio = 0.32;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const listWidth = Math.max(34, Math.min(58, Math.floor(available * listRatio)));
|
|
135
|
+
return {
|
|
136
|
+
mode: "split",
|
|
137
|
+
listWidth,
|
|
138
|
+
previewWidth: Math.max(0, available - listWidth),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const cleanPreviewText = (text: string): string => text.replace(/\s+$/g, "");
|
|
143
|
+
|
|
144
|
+
function textBlocksFromContent(content: PreviewContent | undefined): PreviewBlock[] {
|
|
145
|
+
if (!content) return [];
|
|
146
|
+
if (typeof content === "string") return content.trim() ? [{ kind: "assistant", text: cleanPreviewText(content) }] : [];
|
|
147
|
+
|
|
148
|
+
const blocks: PreviewBlock[] = [];
|
|
149
|
+
for (const part of content) {
|
|
150
|
+
if (part.type === "text" && part.text.trim()) {
|
|
151
|
+
blocks.push({ kind: "assistant", text: cleanPreviewText(part.text) });
|
|
152
|
+
} else if (part.type === "image") {
|
|
153
|
+
blocks.push({ kind: "notice", text: `[image${part.mimeType ? `: ${part.mimeType}` : ""}]` });
|
|
154
|
+
} else if (part.type === "toolCall") {
|
|
155
|
+
const args = part.arguments && Object.keys(part.arguments).length > 0 ? JSON.stringify(part.arguments) : undefined;
|
|
156
|
+
blocks.push({ kind: "toolCall", name: part.name, args });
|
|
157
|
+
} else if (part.type === "thinking") {
|
|
158
|
+
blocks.push({
|
|
159
|
+
kind: "thinking",
|
|
160
|
+
text: part.redacted ? "[thinking redacted]" : cleanPreviewText(part.thinking ?? "[thinking]"),
|
|
161
|
+
redacted: part.redacted,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return blocks;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function contentToPlainText(content: PreviewContent | undefined): string {
|
|
169
|
+
if (!content) return "";
|
|
170
|
+
if (typeof content === "string") return content;
|
|
171
|
+
return content
|
|
172
|
+
.map((part) => {
|
|
173
|
+
if (part.type === "text") return part.text;
|
|
174
|
+
if (part.type === "image") return `[image${part.mimeType ? `: ${part.mimeType}` : ""}]`;
|
|
175
|
+
if (part.type === "toolCall") return `[tool call: ${part.name}]`;
|
|
176
|
+
if (part.type === "thinking") return part.redacted ? "[thinking redacted]" : (part.thinking ?? "[thinking]");
|
|
177
|
+
return "";
|
|
178
|
+
})
|
|
179
|
+
.filter(Boolean)
|
|
180
|
+
.join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function messageToBlocks(message: PreviewMessageLike): PreviewBlock[] {
|
|
184
|
+
if (message.role === "user") {
|
|
185
|
+
const text = cleanPreviewText(contentToPlainText(message.content));
|
|
186
|
+
return text ? [{ kind: "user", text }] : [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (message.role === "assistant") {
|
|
190
|
+
return textBlocksFromContent(message.content);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (message.role === "toolResult" || message.role === "tool") {
|
|
194
|
+
const text = cleanPreviewText(contentToPlainText(message.content));
|
|
195
|
+
return text ? [{ kind: "toolResult", name: message.toolName, text, isError: message.isError }] : [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (message.role === "bashExecution") {
|
|
199
|
+
const command = message.command ?? "";
|
|
200
|
+
return command || message.output
|
|
201
|
+
? [{ kind: "bash", command, output: cleanPreviewText(message.output ?? ""), isError: message.isError }]
|
|
202
|
+
: [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (message.role === "compactionSummary" || message.role === "branchSummary") {
|
|
206
|
+
const text = cleanPreviewText(message.summary ?? contentToPlainText(message.content));
|
|
207
|
+
return text ? [{ kind: "summary", label: message.role === "branchSummary" ? "Branch summary" : "Summary", text }] : [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const text = cleanPreviewText(message.summary ?? contentToPlainText(message.content));
|
|
211
|
+
return text ? [{ kind: "custom", label: message.role || "Message", text }] : [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function buildSessionPreview(
|
|
215
|
+
session: SessionInfoLike,
|
|
216
|
+
messages: PreviewMessageLike[],
|
|
217
|
+
options: { maxMessages?: number } = {},
|
|
218
|
+
): SessionPreview {
|
|
219
|
+
const maxMessages = options.maxMessages ?? 80;
|
|
220
|
+
const blocks: PreviewBlock[] = [];
|
|
221
|
+
const omitted = Math.max(0, messages.length - maxMessages);
|
|
222
|
+
const visibleMessages = omitted > 0 ? messages.slice(-maxMessages) : messages;
|
|
223
|
+
|
|
224
|
+
if (omitted > 0) {
|
|
225
|
+
blocks.push({ kind: "notice", text: `… ${omitted} earlier messages omitted` });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const message of visibleMessages) {
|
|
229
|
+
blocks.push(...messageToBlocks(message));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const messageCount = session.messageCount ?? messages.length;
|
|
233
|
+
return {
|
|
234
|
+
title: buildSessionLabel(session),
|
|
235
|
+
subtitle: `${formatTimestamp(session.modified)} · ${messageCount} messages · ${session.cwd}`,
|
|
236
|
+
blocks: blocks.length > 0 ? blocks : [{ kind: "notice", text: "No previewable messages." }],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function buildPreviewError(session: SessionInfoLike, error: unknown): SessionPreview {
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
242
|
+
return {
|
|
243
|
+
title: buildSessionLabel(session),
|
|
244
|
+
subtitle: `${formatTimestamp(session.modified)} · preview unavailable`,
|
|
245
|
+
blocks: [{ kind: "notice", text: `Failed to load preview: ${message}` }],
|
|
246
|
+
error: message,
|
|
247
|
+
};
|
|
248
|
+
}
|