246 lines
8.5 KiB
TypeScript
246 lines
8.5 KiB
TypeScript
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;
|
||
}
|
||
},
|
||
});
|
||
}
|