From a3e64a1fba139bd2823b9bf85e35cfc00e25a64d Mon Sep 17 00:00:00 2001 From: pfergi42 Date: Fri, 20 Mar 2026 08:37:27 -0700 Subject: [PATCH 1/4] tui: add transcript paging controls --- src/tui/components/chat-log.test.ts | 21 ++++++++++ src/tui/components/chat-log.ts | 60 +++++++++++++++++++++++++++++ src/tui/components/custom-editor.ts | 10 +++++ src/tui/tui.ts | 28 +++++++++++--- 4 files changed, 113 insertions(+), 6 deletions(-) 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") { From 2fd5724cfa3d5118bb0e63460cfb724f9dc47912 Mon Sep 17 00:00:00 2001 From: pfergi42 Date: Fri, 20 Mar 2026 09:07:05 -0700 Subject: [PATCH 2/4] tui: scroll transcript with mouse wheel --- src/tui/tui.test.ts | 16 ++++++++++++++++ src/tui/tui.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) 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 acb359b4186..aa9c0a2daad 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -230,6 +230,27 @@ export function resolveInitialTuiAgentId(params: { return normalizeAgentId(params.fallbackAgentId); } +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 +551,23 @@ export async function runTui(opts: TuiOptions) { const tui = new TUI(new ProcessTerminal()); const dedupeBackspace = createBackspaceDeduper(); + tui.addInputListener((data) => { + const mouse = parseMouseWheelEvent(data); + if (!mouse || tui.hasOverlay()) { + return undefined; + } + if (mouse.row < chatViewportTop || mouse.row > chatViewportBottom) { + return undefined; + } + if (mouse.direction === "up") { + chatLog.scrollPageUp(); + } else { + chatLog.scrollPageDown(); + } + tui.requestRender(); + return { consume: true }; + }); + tui.addInputListener((data) => { const next = dedupeBackspace(data); if (next.length === 0) { @@ -542,6 +580,8 @@ export async function runTui(opts: TuiOptions) { const footer = new Text("", 1, 0); const chatLog = new ChatLog(); const editor = new CustomEditor(tui, editorTheme); + let chatViewportTop = 1; + let chatViewportBottom = 1; const root = new (class extends Container { override render(width: number) { const headerLines = header.render(width); @@ -553,6 +593,8 @@ export async function runTui(opts: TuiOptions) { 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]; } })(); @@ -1058,6 +1100,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 = () => { From e2724636d08ef0dbb488422fd2eb866878b8584f Mon Sep 17 00:00:00 2001 From: pfergi42 Date: Fri, 20 Mar 2026 09:18:51 -0700 Subject: [PATCH 3/4] tui: refine transcript wheel scrolling --- src/tui/components/chat-log.ts | 19 ++++++++++++++----- src/tui/tui.ts | 5 +++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index 1f6d8832109..c8b595a8b3d 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -10,6 +10,7 @@ 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; @@ -68,13 +69,19 @@ export class ChatLog extends Container { scrollPageUp() { const page = this.viewportHeight ?? 10; - this.scrollOffset += page; - this.clampScrollOffset(); + this.scrollLines(page); } scrollPageDown() { const page = this.viewportHeight ?? 10; - this.scrollOffset -= page; + this.scrollLines(-page); + } + + scrollLines(delta: number) { + if (!Number.isFinite(delta) || delta === 0) { + return; + } + this.scrollOffset += Math.trunc(delta); this.clampScrollOffset(); } @@ -93,7 +100,7 @@ export class ChatLog extends Container { return Math.max(0, this.getRenderedLineCount(width) - this.viewportHeight); } - private clampScrollOffset(width = 120) { + private clampScrollOffset(width = this.lastRenderWidth) { this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset(width))); } @@ -235,13 +242,15 @@ export class ChatLog extends Container { } override render(width: number) { + this.lastRenderWidth = width; const lines = super.render(width); if (!this.viewportHeight || lines.length <= this.viewportHeight) { this.scrollOffset = 0; return lines; } - this.clampScrollOffset(width); + 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/tui.ts b/src/tui/tui.ts index aa9c0a2daad..f66ce240042 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -560,9 +560,9 @@ export async function runTui(opts: TuiOptions) { return undefined; } if (mouse.direction === "up") { - chatLog.scrollPageUp(); + chatLog.scrollLines(3); } else { - chatLog.scrollPageDown(); + chatLog.scrollLines(-3); } tui.requestRender(); return { consume: true }; @@ -917,6 +917,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); From fcecbb2eff314bc7b18ba2a56bcdd9140ea583e8 Mon Sep 17 00:00:00 2001 From: pfergi42 Date: Fri, 20 Mar 2026 09:48:55 -0700 Subject: [PATCH 4/4] tui: stabilize transcript scrollback --- src/tui/components/chat-log.ts | 6 ++++++ src/tui/tui.ts | 13 ++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/tui/components/chat-log.ts b/src/tui/components/chat-log.ts index c8b595a8b3d..4b1f41fa325 100644 --- a/src/tui/components/chat-log.ts +++ b/src/tui/components/chat-log.ts @@ -50,11 +50,16 @@ 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) { @@ -109,6 +114,7 @@ export class ChatLog extends Container { this.toolById.clear(); this.streamingRuns.clear(); this.btwMessage = null; + this.scrollToLatest(); } addSystem(text: string) { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index f66ce240042..e93c9e43949 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -230,6 +230,10 @@ 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) { @@ -552,12 +556,15 @@ export async function runTui(opts: TuiOptions) { const tui = new TUI(new ProcessTerminal()); const dedupeBackspace = createBackspaceDeduper(); tui.addInputListener((data) => { - const mouse = parseMouseWheelEvent(data); - if (!mouse || tui.hasOverlay()) { + if (!isMouseSgrSequence(data)) { return undefined; } + const mouse = parseMouseWheelEvent(data); + if (!mouse || tui.hasOverlay()) { + return { consume: true }; + } if (mouse.row < chatViewportTop || mouse.row > chatViewportBottom) { - return undefined; + return { consume: true }; } if (mouse.direction === "up") { chatLog.scrollLines(3);