diff --git a/src/tui/components/chat-log.test.ts b/src/tui/components/chat-log.test.ts index 700a2abb9d2..7c976e9be3d 100644 --- a/src/tui/components/chat-log.test.ts +++ b/src/tui/components/chat-log.test.ts @@ -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"); + }); }); diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index c46e6065b9b..1f6d8832109 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -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(); private streamingRuns = new Map(); 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); + } } diff --git a/src/tui/components/custom-editor.ts b/src/tui/components/custom-editor.ts index 4dc42391f2e..c204788c613 100644 --- a/src/tui/components/custom-editor.ts +++ b/src/tui/components/custom-editor.ts @@ -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; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index b9c67e76a29..acb359b4186 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -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") {