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..4b1f41fa325 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -8,6 +8,9 @@ import { UserMessageComponent } from "./user-message.js"; export class ChatLog extends Container { private readonly maxComponents: number; + private viewportHeight: number | null = null; + private scrollOffset = 0; + private lastRenderWidth = 120; private toolById = new Map(); private streamingRuns = new Map(); private btwMessage: BtwInlineMessage | null = null; @@ -46,8 +49,64 @@ export class ChatLog extends Container { } private append(component: Component) { + const wasAtLatest = this.scrollOffset === 0; + const previousLineCount = wasAtLatest ? 0 : this.getRenderedLineCount(this.lastRenderWidth); this.addChild(component); this.pruneOverflow(); + if (wasAtLatest) { + this.scrollToLatest(); + return; + } + const nextLineCount = this.getRenderedLineCount(this.lastRenderWidth); + this.scrollOffset += Math.max(0, nextLineCount - previousLineCount); + this.clampScrollOffset(); + } + + 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.scrollLines(page); + } + + scrollPageDown() { + const page = this.viewportHeight ?? 10; + this.scrollLines(-page); + } + + scrollLines(delta: number) { + if (!Number.isFinite(delta) || delta === 0) { + return; + } + this.scrollOffset += Math.trunc(delta); + 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 = this.lastRenderWidth) { + this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset(width))); } clearAll() { @@ -55,6 +114,7 @@ export class ChatLog extends Container { this.toolById.clear(); this.streamingRuns.clear(); this.btwMessage = null; + this.scrollToLatest(); } addSystem(text: string) { @@ -186,4 +246,19 @@ export class ChatLog extends Container { tool.setExpanded(expanded); } } + + override render(width: number) { + this.lastRenderWidth = width; + const lines = super.render(width); + if (!this.viewportHeight || lines.length <= this.viewportHeight) { + this.scrollOffset = 0; + return lines; + } + + const maxOffset = Math.max(0, lines.length - this.viewportHeight); + this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxOffset)); + 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.test.ts b/src/tui/tui.test.ts index 773c03f6de3..c2694f02818 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { parseMouseWheelEvent } from "./tui.js"; import type { OpenClawConfig } from "../config/config.js"; import { getSlashCommands, parseCommand } from "./commands.js"; import { @@ -262,3 +263,18 @@ describe("TUI shutdown safety", () => { }).toThrow("boom"); }); }); + + +describe("parseMouseWheelEvent", () => { + it("parses wheel up sgr mouse sequences", () => { + expect(parseMouseWheelEvent("\x1b[<64;20;5M")).toEqual({ direction: "up", col: 20, row: 5 }); + }); + + it("parses wheel down sgr mouse sequences with modifiers", () => { + expect(parseMouseWheelEvent("\x1b[<69;7;11M")).toEqual({ direction: "down", col: 7, row: 11 }); + }); + + it("ignores non-wheel mouse sequences", () => { + expect(parseMouseWheelEvent("\x1b[<0;20;5M")).toBeNull(); + }); +}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index b9c67e76a29..e93c9e43949 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -230,6 +230,31 @@ export function resolveInitialTuiAgentId(params: { return normalizeAgentId(params.fallbackAgentId); } +function isMouseSgrSequence(data: string): boolean { + return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data); +} + +export function parseMouseWheelEvent(data: string): { direction: "up" | "down"; row: number; col: number } | null { + const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data); + if (!match) { + return null; + } + const code = Number.parseInt(match[1] ?? "", 10); + const col = Number.parseInt(match[2] ?? "", 10); + const row = Number.parseInt(match[3] ?? "", 10); + if (!Number.isFinite(code) || !Number.isFinite(col) || !Number.isFinite(row)) { + return null; + } + const baseCode = code & ~4 & ~8 & ~16; + if (baseCode === 64) { + return { direction: "up", row, col }; + } + if (baseCode === 65) { + return { direction: "down", row, col }; + } + return null; +} + export function resolveGatewayDisconnectState(reason?: string): { connectionStatus: string; activityStatus: string; @@ -530,6 +555,26 @@ export async function runTui(opts: TuiOptions) { const tui = new TUI(new ProcessTerminal()); const dedupeBackspace = createBackspaceDeduper(); + tui.addInputListener((data) => { + if (!isMouseSgrSequence(data)) { + return undefined; + } + const mouse = parseMouseWheelEvent(data); + if (!mouse || tui.hasOverlay()) { + return { consume: true }; + } + if (mouse.row < chatViewportTop || mouse.row > chatViewportBottom) { + return { consume: true }; + } + if (mouse.direction === "up") { + chatLog.scrollLines(3); + } else { + chatLog.scrollLines(-3); + } + tui.requestRender(); + return { consume: true }; + }); + tui.addInputListener((data) => { const next = dedupeBackspace(data); if (next.length === 0) { @@ -542,12 +587,24 @@ 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); + let chatViewportTop = 1; + let chatViewportBottom = 1; + 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); + chatViewportTop = headerLines.length + 1; + chatViewportBottom = chatViewportTop + Math.max(chatLines.length, 1) - 1; + return [...headerLines, ...chatLines, ...statusLines, ...footerLines, ...editorLines]; + } + })(); const updateAutocompleteProvider = () => { editor.setAutocompleteProvider( @@ -564,6 +621,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") { @@ -859,6 +924,7 @@ export async function runTui(opts: TuiOptions) { return; } exitRequested = true; + tui.terminal.write("\x1b[?1000l\x1b[?1002l\x1b[?1006l"); client.stop(); stopTuiSafely(() => tui.stop()); process.exit(0); @@ -1042,6 +1108,7 @@ export async function runTui(opts: TuiOptions) { process.on("SIGINT", sigintHandler); process.on("SIGTERM", sigtermHandler); tui.start(); + tui.terminal.write("\x1b[?1000h\x1b[?1002h\x1b[?1006h"); client.start(); await new Promise((resolve) => { const finish = () => {