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 |
@@ -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
- - `ctx.ui.setEditorText(compiledPrompt)`
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 })` then `ctx.ui.setEditorText()`
445
- - **Why**: Creates proper session lineage tracking; setEditorText prefills the prompt for user to review and submit when ready
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 = 5;
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, _kb, done) => {
331
+ return ctx.ui.custom<SessionInfoLike | null>((tui, theme, kb, done) => {
117
332
  let filter = "";
118
333
  let selectList: SelectList;
119
- const container = new Container();
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
- const filtered = filterSessionEntries(entries, filter);
130
- const items = buildItems(filtered);
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) => container.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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-extensions",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Collection of extensions for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {