Files
dotfiles/pi/.pi/agent/extensions/transcript-viewer.ts
2026-07-24 12:52:00 +02:00

246 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { wrapTextWithAnsi, visibleWidth, truncateToWidth, matchesKey, Key } from "@earendil-works/pi-tui";
import { topBorder, splitBorder, sanitize } from "./lib/boxes.ts";
/**
* transcript-viewer: A scrollable transcript overlay with a claude-cloak style
* scrollbar. User messages are marked with `*` on the scrollbar track and you
* can jump between them.
*
* pi has no API to scroll the live chat (it lives in the terminal's native
* scrollback), so this renders the session transcript in an overlay with its
* own scroll state.
*
* Keys:
* ctrl+q open the viewer
* n / N jump to next / previous user message
* j / k, ↓ / ↑ scroll one line
* ctrl+d / ctrl+u half page down / up
* g / G top / bottom
* q / esc close
*/
function contentToText(content: any): string {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block: any) => (block?.type === "text" ? block.text : block?.type === "image" ? "[image]" : ""))
.filter((t: string) => t !== "")
.join("\n");
}
return "";
}
function argsSummary(args: any) {
if (!args) return "";
const preferred = ["command", "path", "pattern", "url"];
for (const key of preferred) {
if (typeof args[key] === "string") return args[key].replace(/\s+/g, " ");
}
const first = Object.values(args).find((v) => typeof v === "string");
return first ? (first as string).replace(/\s+/g, " ") : "";
}
interface Item {
kind: "user" | "assistant" | "tool" | "info";
text: string;
}
/** Flatten the current session branch into displayable items. */
function buildItems(ctx: any): Item[] {
const items: Item[] = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message") {
const msg = entry.message;
if (msg.role === "user") {
const text = contentToText(msg.content).trim();
if (text) items.push({ kind: "user", text });
} else if (msg.role === "assistant") {
for (const block of msg.content ?? []) {
if (block?.type === "text" && block.text?.trim()) {
items.push({ kind: "assistant", text: block.text.trim() });
} else if (block?.type === "toolCall") {
items.push({ kind: "tool", text: `${block.name} ${argsSummary(block.arguments)}`.trim() });
}
}
}
} else if (entry.type === "custom_message" && entry.display) {
const text = contentToText(entry.content).trim();
if (text) items.push({ kind: "info", text });
} else if (entry.type === "compaction") {
items.push({ kind: "info", text: "— context compacted —" });
} else if (entry.type === "branch_summary") {
items.push({ kind: "info", text: "— branched —" });
}
}
return items;
}
class TranscriptViewer {
/** -1 = not yet laid out; opens scrolled to the bottom. */
scroll = -1;
cacheWidth = -1;
lines: string[] = [];
userOffsets: number[] = [];
lastViewHeight = 10;
lastMaxScroll = 0;
constructor(
public items: Item[],
public tui: any,
public theme: any,
public done: (v?: any) => void,
) {}
invalidate() {
this.cacheWidth = -1;
}
/** Wrap all items to the inner width; record the first line of each user message. */
layout(inner: number) {
if (this.cacheWidth === inner) return;
this.cacheWidth = inner;
this.lines = [];
this.userOffsets = [];
const theme = this.theme;
for (const item of this.items) {
if (this.lines.length > 0) this.lines.push("");
const text = sanitize(item.text);
switch (item.kind) {
case "user": {
this.userOffsets.push(this.lines.length);
const wrapped = wrapTextWithAnsi(text, Math.max(1, inner - 2));
wrapped.forEach((line: string, i: number) => {
this.lines.push(
theme.fg("accent", theme.bold(i === 0 ? " " : " ")) + theme.fg("userMessageText", theme.bold(line)),
);
});
break;
}
case "assistant":
for (const line of wrapTextWithAnsi(text, inner)) this.lines.push(line);
break;
case "tool":
this.lines.push(theme.fg("muted", truncateToWidth(`${text}`, inner, "…")));
break;
case "info":
this.lines.push(theme.fg("dim", theme.italic(truncateToWidth(text, inner, "…"))));
break;
}
}
}
render(width: number) {
const theme = this.theme;
const border = (s: string) => theme.fg("border", s);
if (width < 12) return [truncateToWidth(theme.fg("dim", "too narrow"), Math.max(1, width))];
const rows = this.tui?.terminal?.rows || process.stdout.rows || 24;
const viewHeight = Math.max(5, rows - 8);
// Layout: "│ " + content + " " + scrollbar-column
const inner = width - 4;
this.layout(inner);
const total = this.lines.length;
const maxScroll = Math.max(0, total - viewHeight);
if (this.scroll === -1) this.scroll = maxScroll;
this.scroll = Math.max(0, Math.min(this.scroll, maxScroll));
this.lastViewHeight = viewHeight;
this.lastMaxScroll = maxScroll;
// Scrollbar track: markers map the WHOLE transcript onto the track,
// thumb shows the current viewport (claude-cloak math).
const markerRows = new Set<number>();
for (const offset of this.userOffsets) {
const row = total <= viewHeight ? offset : Math.floor((offset * viewHeight) / total);
markerRows.add(Math.min(row, viewHeight - 1));
}
let thumbTop = -1;
let thumbLen = 0;
if (total > viewHeight) {
thumbLen = Math.min(viewHeight, Math.max(1, Math.floor((viewHeight * viewHeight) / total)));
thumbTop = maxScroll === 0 ? 0 : Math.floor((this.scroll * (viewHeight - thumbLen)) / maxScroll);
}
const out: string[] = [];
out.push(topBorder(width, theme.fg("toolTitle", theme.bold("transcript")), border));
for (let row = 0; row < viewHeight; row++) {
const line = this.lines[this.scroll + row] ?? "";
const pad = Math.max(0, inner - visibleWidth(line));
let track: string;
if (thumbTop !== -1 && row >= thumbTop && row < thumbTop + thumbLen) {
track = theme.fg("accent", "█");
} else if (markerRows.has(row)) {
track = theme.fg("warning", theme.bold("*"));
} else {
track = border("│");
}
out.push(border("│ ") + line + " ".repeat(pad) + " " + track);
}
const hints = theme.fg("dim", "n/N prompts · j/k · ctrl+d/u · g/G · q closes");
const pos = theme.fg("dim", `${Math.min(total, this.scroll + viewHeight)}/${total}`);
out.push(splitBorder(width, hints, pos, border, ["╰", "╯"]));
return out;
}
handleInput(data: string) {
const half = Math.max(1, Math.floor(this.lastViewHeight / 2));
if (data === "q" || matchesKey(data, Key.escape)) {
this.done();
return;
} else if (data === "j" || matchesKey(data, Key.down)) {
this.scroll += 1;
} else if (data === "k" || matchesKey(data, Key.up)) {
this.scroll -= 1;
} else if (matchesKey(data, Key.ctrl("d")) || matchesKey(data, Key.pageDown)) {
this.scroll += half;
} else if (matchesKey(data, Key.ctrl("u")) || matchesKey(data, Key.pageUp)) {
this.scroll -= half;
} else if (data === "g" || matchesKey(data, Key.home)) {
this.scroll = 0;
} else if (data === "G" || matchesKey(data, Key.end)) {
this.scroll = this.lastMaxScroll;
} else if (data === "n") {
const next = this.userOffsets.find((o) => o > this.scroll);
if (next !== undefined) this.scroll = next;
} else if (data === "N") {
const prev = [...this.userOffsets].reverse().find((o) => o < this.scroll);
if (prev !== undefined) this.scroll = prev;
} else {
return;
}
this.scroll = Math.max(0, Math.min(this.scroll, this.lastMaxScroll));
this.tui.requestRender();
}
}
export default function (pi: any) {
let open = false;
pi.registerShortcut("ctrl+q", {
description: "Open transcript viewer (scrollbar + user-message jumps)",
handler: async (ctx: any) => {
if (open) return;
open = true;
try {
await ctx.ui.custom(
(tui: any, theme: any, _keybindings: any, done: any) =>
new TranscriptViewer(buildItems(ctx), tui, theme, () => done(undefined)),
{
overlay: true,
overlayOptions: {
width: "85%",
minWidth: 50,
anchor: "center",
},
},
);
} finally {
open = false;
}
},
});
}