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 = () => {