pi-agent-extensions 0.4.2 → 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.
|
@@ -80,7 +80,7 @@ Implementing the `/handoff` command as specified in `docs/spec_handoff.md`. Foll
|
|
|
80
80
|
| 3 | LLM extraction | Done | - | extraction.ts with retry logic |
|
|
81
81
|
| 3 | Loader UI | Done | - | progress.ts with ProgressLoader + BorderedLoader fallback |
|
|
82
82
|
| 4 | Editor review | Done | - | Using ctx.ui.editor() |
|
|
83
|
-
| 4 | Session creation | Done | - | Using ctx.newSession() with parent tracking |
|
|
83
|
+
| 4 | Session creation | Done | - | Using ctx.newSession() with parent tracking and withSession |
|
|
84
84
|
| 5 | Non-UI mode | Done | - | Print to stdout |
|
|
85
85
|
| 5 | Documentation | Done | - | docs/handoff.md |
|
|
86
86
|
| 5 | Edge cases | Done | - | Error handling, model resolution |
|
package/docs/dev/handoff/spec.md
CHANGED
|
@@ -124,8 +124,8 @@ When goal is too short or vague (e.g., “continue”, “fix”):
|
|
|
124
124
|
- Optionally prepend `/skill:<name>` if a skill was used last.
|
|
125
125
|
- Optionally prepend a short handoff preamble to set expectations in the new thread.
|
|
126
126
|
5. **Create new session**:
|
|
127
|
-
- `ctx.newSession({ parentSession: currentSessionFile })`
|
|
128
|
-
- `
|
|
127
|
+
- `ctx.newSession({ parentSession: currentSessionFile, withSession })`
|
|
128
|
+
- inside `withSession(newCtx)`, call `newCtx.ui.setEditorText(compiledPrompt)`
|
|
129
129
|
|
|
130
130
|
## Output Schema (LLM → Extension)
|
|
131
131
|
```json
|
|
@@ -441,8 +441,8 @@ The following decisions were made during implementation planning:
|
|
|
441
441
|
- **Why**: Provides valuable context about the codebase state at handoff time
|
|
442
442
|
|
|
443
443
|
#### Session Creation Flow
|
|
444
|
-
- **Decision**: Use `ctx.newSession({ parentSession })`
|
|
445
|
-
- **Why**: Creates proper session lineage tracking; setEditorText prefills the prompt for user
|
|
444
|
+
- **Decision**: Use `ctx.newSession({ parentSession, withSession })` and perform editor setup inside `withSession(newCtx)`
|
|
445
|
+
- **Why**: Creates proper session lineage tracking and avoids using a stale command context after session replacement; `newCtx.ui.setEditorText()` prefills the prompt for user review and submission
|
|
446
446
|
|
|
447
447
|
#### Documentation
|
|
448
448
|
- **Decision**: Create `docs/handoff.md` with usage and examples
|
|
@@ -196,19 +196,21 @@ async function runHandoffCommand(
|
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
// Create new session with parent tracking
|
|
199
|
+
// Create new session with parent tracking.
|
|
200
|
+
// Any post-session-replacement work must happen inside withSession,
|
|
201
|
+
// using the fresh ctx passed by Pi.
|
|
200
202
|
const newSessionResult = await ctx.newSession({
|
|
201
203
|
parentSession: currentSessionFile,
|
|
204
|
+
withSession: async (newCtx) => {
|
|
205
|
+
newCtx.ui.setEditorText(editedPrompt);
|
|
206
|
+
newCtx.ui.notify("Handoff ready. Press Enter to send.", "info");
|
|
207
|
+
},
|
|
202
208
|
});
|
|
203
209
|
|
|
204
210
|
if (newSessionResult.cancelled) {
|
|
205
211
|
ctx.ui.notify("New session cancelled", "info");
|
|
206
212
|
return;
|
|
207
213
|
}
|
|
208
|
-
|
|
209
|
-
// Set the edited prompt in the editor for submission
|
|
210
|
-
ctx.ui.setEditorText(editedPrompt);
|
|
211
|
-
ctx.ui.notify("Handoff ready. Press Enter to send.", "info");
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
/**
|
|
@@ -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
|
+
}
|