Merge fcecbb2eff314bc7b18ba2a56bcdd9140ea583e8 into d78e13f545136fcbba1feceecc5e0485a06c33a6
This commit is contained in:
commit
00f380b3c2
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<string, ToolExecutionComponent>();
|
||||
private streamingRuns = new Map<string, AssistantMessageComponent>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<void>((resolve) => {
|
||||
const finish = () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user