live: connect ship-log extension to /live page, fix repo link
This commit is contained in:
216
extensions/session-replay.ts
Normal file
216
extensions/session-replay.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { applyExtensionDefaults } from "./themeMap.ts";
|
||||
import {
|
||||
Box, Text, Markdown, Container, Spacer,
|
||||
matchesKey, Key, truncateToWidth, getMarkdownTheme
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { DynamicBorder, getMarkdownTheme as getPiMdTheme } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// Minimal shim for timestamp handling if not directly in Message objects
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
function getElapsedTime(start: Date, end: Date): string {
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
return `${diffMin}m ${diffSec % 60}s`;
|
||||
}
|
||||
|
||||
interface HistoryItem {
|
||||
type: 'user' | 'assistant' | 'tool';
|
||||
title: string;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
elapsed?: string;
|
||||
}
|
||||
|
||||
class SessionReplayUI {
|
||||
private selectedIndex = 0;
|
||||
private expandedIndex: number | null = null;
|
||||
private scrollOffset = 0;
|
||||
|
||||
constructor(
|
||||
private items: HistoryItem[],
|
||||
private onDone: () => void
|
||||
) {
|
||||
// Start selected at the bottom (most recent)
|
||||
this.selectedIndex = Math.max(0, items.length - 1);
|
||||
this.ensureVisible(20); // rough height estimate
|
||||
}
|
||||
|
||||
handleInput(data: string, tui: any): void {
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
this.expandedIndex = this.expandedIndex === this.selectedIndex ? null : this.selectedIndex;
|
||||
} else if (matchesKey(data, Key.escape)) {
|
||||
this.onDone();
|
||||
return;
|
||||
}
|
||||
tui.requestRender();
|
||||
}
|
||||
|
||||
private ensureVisible(height: number) {
|
||||
// Simple scroll window logic
|
||||
const pageSize = Math.floor(height / 3); // Approx items per page
|
||||
if (this.selectedIndex < this.scrollOffset) {
|
||||
this.scrollOffset = this.selectedIndex;
|
||||
} else if (this.selectedIndex >= this.scrollOffset + pageSize) {
|
||||
this.scrollOffset = this.selectedIndex - pageSize + 1;
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number, height: number, theme: any): string[] {
|
||||
this.ensureVisible(height);
|
||||
|
||||
const container = new Container();
|
||||
const mdTheme = getPiMdTheme();
|
||||
|
||||
// Header
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
container.addChild(new Text(`${theme.fg("accent", theme.bold(" SESSION REPLAY"))} ${theme.fg("dim", "|")} ${theme.fg("success", this.items.length.toString())} entries`, 1, 0));
|
||||
container.addChild(new Spacer(1));
|
||||
|
||||
// Calculate visible range
|
||||
const visibleItems = this.items.slice(this.scrollOffset);
|
||||
|
||||
visibleItems.forEach((item, idx) => {
|
||||
const absoluteIndex = idx + this.scrollOffset;
|
||||
const isSelected = absoluteIndex === this.selectedIndex;
|
||||
const isExpanded = absoluteIndex === this.expandedIndex;
|
||||
|
||||
const cardBox = new Box(1, 0, (s) => isSelected ? theme.bg("selectedBg", s) : s);
|
||||
|
||||
// Icon and Title
|
||||
let icon = "○";
|
||||
let color = "dim";
|
||||
if (item.type === 'user') { icon = "👤"; color = "success"; }
|
||||
else if (item.type === 'assistant') { icon = "🤖"; color = "accent"; }
|
||||
else if (item.type === 'tool') { icon = "🛠️"; color = "warning"; }
|
||||
|
||||
const timeStr = theme.fg("success", `[${formatTime(item.timestamp)}]`);
|
||||
const elapsedStr = item.elapsed ? theme.fg("dim", ` (+${item.elapsed})`) : "";
|
||||
|
||||
const titleLine = `${theme.fg(color, icon)} ${theme.bold(item.title)} ${timeStr}${elapsedStr}`;
|
||||
cardBox.addChild(new Text(titleLine, 0, 0));
|
||||
|
||||
if (isExpanded) {
|
||||
cardBox.addChild(new Spacer(1));
|
||||
cardBox.addChild(new Markdown(item.content, 2, 0, mdTheme));
|
||||
} else {
|
||||
// Truncated preview
|
||||
const preview = item.content.replace(/\n/g, ' ').substring(0, width - 10);
|
||||
cardBox.addChild(new Text(theme.fg("dim", " " + preview + "..."), 0, 0));
|
||||
}
|
||||
|
||||
container.addChild(cardBox);
|
||||
// Don't add too many spacers if we have many items
|
||||
if (visibleItems.length < 15) container.addChild(new Spacer(1));
|
||||
});
|
||||
|
||||
// Footer
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", " ↑/↓ Navigate • Enter Expand • Esc Close"), 1, 0));
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return container.render(width);
|
||||
}
|
||||
}
|
||||
|
||||
function extractContent(entry: any): string {
|
||||
const msg = entry.message;
|
||||
if (!msg) return "";
|
||||
const content = msg.content;
|
||||
if (!content) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((c: any) => {
|
||||
if (c.type === "text") return c.text || "";
|
||||
if (c.type === "toolCall") return `Tool: ${c.name}(${JSON.stringify(c.arguments).slice(0, 200)})`;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
return JSON.stringify(content).slice(0, 500);
|
||||
}
|
||||
|
||||
export default function(pi: ExtensionAPI) {
|
||||
pi.registerCommand("replay", {
|
||||
description: "Show a scrollable timeline of the current session",
|
||||
handler: async (args, ctx) => {
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const items: HistoryItem[] = [];
|
||||
|
||||
let prevTime: Date | null = null;
|
||||
|
||||
for (const entry of branch) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msg = entry.message;
|
||||
if (!msg) continue;
|
||||
|
||||
const ts = msg.timestamp ? new Date(msg.timestamp) : new Date();
|
||||
const elapsed = prevTime ? getElapsedTime(prevTime, ts) : undefined;
|
||||
prevTime = ts;
|
||||
|
||||
const role = msg.role;
|
||||
const text = extractContent(entry);
|
||||
if (!text) continue;
|
||||
|
||||
if (role === "user") {
|
||||
items.push({
|
||||
type: "user",
|
||||
title: "User Prompt",
|
||||
content: text,
|
||||
timestamp: ts,
|
||||
elapsed,
|
||||
});
|
||||
} else if (role === "assistant") {
|
||||
items.push({
|
||||
type: "assistant",
|
||||
title: "Assistant",
|
||||
content: text,
|
||||
timestamp: ts,
|
||||
elapsed,
|
||||
});
|
||||
} else if (role === "toolResult") {
|
||||
const toolName = (msg as any).toolName || "tool";
|
||||
items.push({
|
||||
type: "tool",
|
||||
title: `Tool: ${toolName}`,
|
||||
content: text,
|
||||
timestamp: ts,
|
||||
elapsed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
ctx.ui.notify("No session history found.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.ui.custom((tui, theme, kb, done) => {
|
||||
const component = new SessionReplayUI(items, () => done(undefined));
|
||||
return {
|
||||
render: (w) => component.render(w, 30, theme),
|
||||
handleInput: (data) => component.handleInput(data, tui),
|
||||
invalidate: () => {},
|
||||
};
|
||||
}, {
|
||||
overlay: true,
|
||||
overlayOptions: { width: "80%", anchor: "center" },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user