tui: add transcript paging controls

This commit is contained in:
pfergi42 2026-03-20 08:37:27 -07:00
parent 06845a1974
commit a3e64a1fba
4 changed files with 113 additions and 6 deletions

View File

@ -73,4 +73,25 @@ describe("ChatLog", () => {
expect(rendered).not.toContain("BTW: what is 17 * 19?");
expect(chatLog.hasVisibleBtw()).toBe(false);
});
it("supports paging back through transcript history", () => {
const chatLog = new ChatLog(80);
for (let i = 1; i <= 12; i++) {
chatLog.addSystem(`system-${i}`);
}
chatLog.setViewportHeight(6);
let rendered = chatLog.render(120).join("\n");
expect(rendered).toContain("system-12");
expect(rendered).not.toContain("system-7");
chatLog.scrollPageUp();
rendered = chatLog.render(120).join("\n");
expect(rendered).toContain("system-7");
expect(rendered).not.toContain("system-12");
chatLog.scrollToLatest();
rendered = chatLog.render(120).join("\n");
expect(rendered).toContain("system-12");
});
});

View File

@ -8,6 +8,8 @@ import { UserMessageComponent } from "./user-message.js";
export class ChatLog extends Container {
private readonly maxComponents: number;
private viewportHeight: number | null = null;
private scrollOffset = 0;
private toolById = new Map<string, ToolExecutionComponent>();
private streamingRuns = new Map<string, AssistantMessageComponent>();
private btwMessage: BtwInlineMessage | null = null;
@ -46,8 +48,53 @@ export class ChatLog extends Container {
}
private append(component: Component) {
const wasAtLatest = this.scrollOffset === 0;
this.addChild(component);
this.pruneOverflow();
if (wasAtLatest) {
this.scrollToLatest();
}
}
setViewportHeight(height: number | null) {
if (height === null || Number.isNaN(height)) {
this.viewportHeight = null;
this.clampScrollOffset();
return;
}
this.viewportHeight = Math.max(1, Math.floor(height));
this.clampScrollOffset();
}
scrollPageUp() {
const page = this.viewportHeight ?? 10;
this.scrollOffset += page;
this.clampScrollOffset();
}
scrollPageDown() {
const page = this.viewportHeight ?? 10;
this.scrollOffset -= page;
this.clampScrollOffset();
}
scrollToLatest() {
this.scrollOffset = 0;
}
private getRenderedLineCount(width: number) {
return super.render(width).length;
}
private getMaxScrollOffset(width: number) {
if (!this.viewportHeight) {
return 0;
}
return Math.max(0, this.getRenderedLineCount(width) - this.viewportHeight);
}
private clampScrollOffset(width = 120) {
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset(width)));
}
clearAll() {
@ -186,4 +233,17 @@ export class ChatLog extends Container {
tool.setExpanded(expanded);
}
}
override render(width: number) {
const lines = super.render(width);
if (!this.viewportHeight || lines.length <= this.viewportHeight) {
this.scrollOffset = 0;
return lines;
}
this.clampScrollOffset(width);
const start = Math.max(0, lines.length - this.viewportHeight - this.scrollOffset);
const end = start + this.viewportHeight;
return lines.slice(start, end);
}
}

View File

@ -11,6 +11,8 @@ export class CustomEditor extends Editor {
onCtrlT?: () => void;
onShiftTab?: () => void;
onAltEnter?: () => void;
onPageUp?: () => void;
onPageDown?: () => void;
handleInput(data: string): void {
if (matchesKey(data, Key.alt("enter")) && this.onAltEnter) {
@ -37,6 +39,14 @@ export class CustomEditor extends Editor {
this.onCtrlT();
return;
}
if (matchesKey(data, Key.pageUp) && this.onPageUp) {
this.onPageUp();
return;
}
if (matchesKey(data, Key.pageDown) && this.onPageDown) {
this.onPageDown();
return;
}
if (matchesKey(data, Key.shift("tab")) && this.onShiftTab) {
this.onShiftTab();
return;

View File

@ -542,12 +542,20 @@ export async function runTui(opts: TuiOptions) {
const footer = new Text("", 1, 0);
const chatLog = new ChatLog();
const editor = new CustomEditor(tui, editorTheme);
const root = new Container();
root.addChild(header);
root.addChild(chatLog);
root.addChild(statusContainer);
root.addChild(footer);
root.addChild(editor);
const root = new (class extends Container {
override render(width: number) {
const headerLines = header.render(width);
const statusLines = statusContainer.render(width);
const footerLines = footer.render(width);
const editorLines = editor.render(width);
const reserved =
headerLines.length + statusLines.length + footerLines.length + editorLines.length;
const available = Math.max(1, tui.terminal.rows - reserved);
chatLog.setViewportHeight(available);
const chatLines = chatLog.render(width);
return [...headerLines, ...chatLines, ...statusLines, ...footerLines, ...editorLines];
}
})();
const updateAutocompleteProvider = () => {
editor.setAutocompleteProvider(
@ -564,6 +572,14 @@ export async function runTui(opts: TuiOptions) {
tui.addChild(root);
tui.setFocus(editor);
editor.onPageUp = () => {
chatLog.scrollPageUp();
tui.requestRender();
};
editor.onPageDown = () => {
chatLog.scrollPageDown();
tui.requestRender();
};
const formatSessionKey = (key: string) => {
if (key === "global" || key === "unknown") {