fix(tui): reduce switchable renderer complexity

This commit is contained in:
Armin Ronacher 2026-08-02 14:09:36 +02:00
parent ccde8478dc
commit 25080b9d4c
8 changed files with 121 additions and 551 deletions

View file

@ -20,6 +20,7 @@ import type {
OverlayHandle,
OverlayOptions,
SlashCommand,
Terminal,
} from "@earendil-works/pi-tui";
import * as TuiLayouts from "@earendil-works/pi-tui";
import {
@ -31,7 +32,9 @@ import {
hyperlink,
Markdown,
matchesKey,
ProcessTerminal,
Spacer,
SwitchableTui,
setKeybindings,
Text,
TruncatedText,
@ -98,6 +101,7 @@ import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelo
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts";
import { openBrowser } from "../../utils/open-browser.ts";
import { getCwdRelativePath } from "../../utils/paths.ts";
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
@ -145,7 +149,6 @@ import { TrustSelectorComponent } from "./components/trust-selector.ts";
import { UserMessageComponent } from "./components/user-message.ts";
import { UserMessageSelectorComponent } from "./components/user-message-selector.ts";
import { editInExternalEditor } from "./external-editor.ts";
import { createInteractiveTui, type InteractiveTui } from "./interactive-tui.ts";
import { getModelSearchText } from "./model-search.ts";
import {
getAvailableThemes,
@ -325,11 +328,23 @@ export interface InteractiveModeOptions {
uiMode?: UiMode;
}
export { createInteractiveTui };
export function createInteractiveTui(options: {
uiMode: UiMode;
showHardwareCursor: boolean;
logDirectory: string;
terminal?: Terminal;
}): SwitchableTui {
return new SwitchableTui(options.terminal ?? new ProcessTerminal(), {
mode: options.uiMode === "fullscreen" ? "alternate" : "main",
showHardwareCursor: options.showHardwareCursor,
logDirectory: options.logDirectory,
altScreen: { openUrl: openBrowser },
});
}
export class InteractiveMode {
private runtimeHost: AgentSessionRuntime;
private ui: InteractiveTui;
private ui: SwitchableTui;
private loadedResourcesContainer: Container;
private chatContainer: Container;
private documentContainer: Container;
@ -454,6 +469,9 @@ export class InteractiveMode {
private get settingsManager() {
return this.session.settingsManager;
}
private get uiMode(): UiMode {
return this.ui.mode === "alternate" ? "fullscreen" : "regular";
}
constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) {
this.runtimeHost = runtimeHost;
@ -747,13 +765,7 @@ export class InteractiveMode {
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]),
);
this.ui.addChild(this.documentContainer);
this.ui.addChild(this.pendingMessagesContainer);
this.ui.addChild(this.statusContainer);
this.ui.addChild(this.widgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.widgetContainerBelow);
this.ui.addChild(this.footerContainer);
for (const component of [this.documentContainer, ...dock.children]) this.ui.addChild(component);
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
@ -1915,7 +1927,7 @@ export class InteractiveMode {
this.activeStatusIndicator?.dispose();
this.activeStatusIndicator = undefined;
this.statusContainer.clear();
if (hadActiveStatusIndicator && this.ui.uiMode === "regular" && this.ui.getClearOnShrink()) {
if (hadActiveStatusIndicator && this.uiMode === "regular" && this.ui.getClearOnShrink()) {
this.statusContainer.addChild(this.idleStatus);
}
}
@ -4189,8 +4201,7 @@ export class InteractiveMode {
private showSettingsSelector(): void {
this.showSelector((done) => {
let selector: SettingsSelectorComponent | undefined;
selector = new SettingsSelectorComponent(
const selector = new SettingsSelectorComponent(
{
autoCompact: this.session.autoCompactionEnabled,
showImages: this.settingsManager.getShowImages(),
@ -4221,7 +4232,7 @@ export class InteractiveMode {
quietStartup: this.settingsManager.getQuietStartup(),
clearOnShrink: this.settingsManager.getClearOnShrink(),
showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
uiMode: this.ui.uiMode,
uiMode: this.uiMode,
fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
warnings: this.settingsManager.getWarnings(),
},
@ -4364,8 +4375,8 @@ export class InteractiveMode {
this.settingsManager.setShowTerminalProgress(enabled);
},
onUiModeChange: (mode) => {
if (!this.ui.setUiMode(mode)) {
selector?.getSettingsList().updateValue("ui-mode", this.ui.uiMode);
if (!this.ui.setMode(mode === "fullscreen" ? "alternate" : "main")) {
selector.getSettingsList().updateValue("ui-mode", this.uiMode);
this.showStatus("Close active overlays before changing UI mode");
return;
}

View file

@ -1,34 +0,0 @@
import { ProcessTerminal, SwitchableTui, type Terminal } from "@earendil-works/pi-tui";
import type { UiMode } from "../../core/settings-manager.ts";
import { openBrowser } from "../../utils/open-browser.ts";
export interface InteractiveTuiOptions {
uiMode: UiMode;
showHardwareCursor: boolean;
logDirectory: string;
terminal?: Terminal;
}
/** Interactive TUI with coding-agent mode names and URL activation. */
export class InteractiveTui extends SwitchableTui {
constructor(options: InteractiveTuiOptions) {
super(options.terminal ?? new ProcessTerminal(), {
mode: options.uiMode === "fullscreen" ? "alternate" : "main",
showHardwareCursor: options.showHardwareCursor,
logDirectory: options.logDirectory,
altScreen: { openUrl: openBrowser },
});
}
get uiMode(): UiMode {
return this.mode === "alternate" ? "fullscreen" : "regular";
}
setUiMode(mode: UiMode): boolean {
return this.setMode(mode === "fullscreen" ? "alternate" : "main");
}
}
export function createInteractiveTui(options: InteractiveTuiOptions): InteractiveTui {
return new InteractiveTui(options);
}

View file

@ -6,45 +6,24 @@ import { createInteractiveTui, InteractiveMode } from "../src/modes/interactive/
class RecordingTerminal extends VirtualTerminal implements Terminal {
readonly writes: string[] = [];
readonly progressChanges: boolean[] = [];
readonly startScreens: Array<"main" | "alternate"> = [];
readonly suspendScreens: Array<"main" | "alternate"> = [];
readonly resumeScreens: Array<"main" | "alternate"> = [];
startCount = 0;
stopCount = 0;
private progressActive = false;
private alternateScreen = false;
override start(onInput: (data: string) => void, onResize: () => void): void {
this.startCount += 1;
this.startScreens.push(this.alternateScreen ? "alternate" : "main");
super.start(onInput, onResize);
}
suspendRenderer(): void {
this.suspendScreens.push(this.alternateScreen ? "alternate" : "main");
}
resumeRenderer(): void {
this.resumeScreens.push(this.alternateScreen ? "alternate" : "main");
}
suspendRenderer(): void {}
resumeRenderer(): void {}
override write(data: string): void {
this.writes.push(data);
if (data.includes("\x1b[?1049h")) this.alternateScreen = true;
if (data.includes("\x1b[?1049l")) this.alternateScreen = false;
super.write(data);
}
override setProgress(active: boolean): void {
this.progressActive = active;
this.progressChanges.push(active);
super.setProgress(active);
}
override stop(): void {
this.stopCount += 1;
if (this.progressActive) this.setProgress(false);
super.stop();
}
}
@ -54,108 +33,53 @@ function createTui(terminal: Terminal, uiMode: "regular" | "fullscreen" = "regul
}
describe("createInteractiveTui", () => {
it("selects the alternate-screen renderer only when requested", async () => {
for (const [uiMode, screen] of [
["regular", "main"],
["fullscreen", "alternate"],
] as const) {
it("selects the requested renderer", async () => {
for (const uiMode of ["regular", "fullscreen"] as const) {
const terminal = new RecordingTerminal();
const tui = createTui(terminal, uiMode);
expect(tui.uiMode).toBe(uiMode);
expect(isViewportTUI(tui)).toBe(uiMode === "fullscreen");
tui.start();
await terminal.waitForRender();
expect(terminal.startScreens).toEqual([screen]);
expect(terminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(uiMode === "fullscreen");
tui.stop();
}
});
it("switches renderers without restarting the terminal or losing focus", async () => {
const terminal = new RecordingTerminal(30, 6);
const terminal = new RecordingTerminal();
const tui = createTui(terminal);
const inputs: string[] = [];
let text = "regular content";
const component: Component = {
render: () => [text],
render: () => ["content"],
handleInput: (data) => inputs.push(data),
invalidate: () => {},
};
tui.addChild(component);
tui.setFocus(component);
terminal.write("shell history\r\n");
tui.start();
await terminal.waitForRender();
tui.terminal.setProgress(true);
const overlay = tui.showOverlay(new Text("overlay", 0, 0));
expect(tui.setUiMode("fullscreen")).toBe(false);
expect(tui.setMode("alternate")).toBe(false);
overlay.hide();
expect(tui.setUiMode("fullscreen")).toBe(true);
await terminal.waitForRender();
expect(terminal.suspendScreens).toEqual(["main"]);
expect(terminal.resumeScreens).toEqual(["alternate"]);
terminal.sendInput("b");
expect(tui.setMode("alternate")).toBe(true);
expect(isViewportTUI(tui)).toBe(true);
terminal.sendInput("a");
expect(tui.setMode("main")).toBe(true);
text = "updated in fullscreen";
tui.requestRender();
await terminal.waitForRender();
expect(tui.setUiMode("regular")).toBe(true);
await terminal.waitForRender();
expect(isViewportTUI(tui)).toBe(false);
expect([terminal.startCount, terminal.stopCount]).toEqual([1, 0]);
expect(terminal.progressChanges).toEqual([true]);
expect(terminal.suspendScreens).toEqual(["main", "alternate"]);
expect(terminal.resumeScreens).toEqual(["alternate", "main"]);
expect(terminal.getViewport().some((line) => line.includes(text))).toBe(true);
expect(terminal.getScrollBuffer().some((line) => line.includes("shell history"))).toBe(true);
terminal.sendInput("c");
expect(inputs).toEqual(["b", "c"]);
expect(inputs).toEqual(["a"]);
tui.stop();
expect(terminal.stopCount).toBe(1);
});
it("restores the main screen once when stopping from fullscreen", async () => {
const terminal = new RecordingTerminal(40, 10);
const tui = createTui(terminal);
let lines = ["header-once", "body-before", "editor"];
const component: Component = {
render: () => lines,
invalidate: () => {},
};
tui.addChild(component);
tui.start();
await terminal.waitForRender();
expect(tui.setUiMode("fullscreen")).toBe(true);
lines = ["header-once", "body-after", "editor"];
tui.requestRender();
await terminal.waitForRender();
tui.stop();
await terminal.flush();
const scrollBuffer = terminal.getScrollBuffer();
expect(scrollBuffer.filter((line) => line.includes("header-once"))).toHaveLength(1);
expect(scrollBuffer.some((line) => line.includes("body-before"))).toBe(false);
expect(scrollBuffer.filter((line) => line.includes("body-after"))).toHaveLength(1);
expect(terminal.suspendScreens).toEqual(["main", "alternate"]);
expect(terminal.resumeScreens).toEqual(["alternate"]);
expect(tui.uiMode).toBe("fullscreen");
terminal.write("\x1b[2J\x1b[Hexternal application");
tui.start();
await terminal.waitForRender();
tui.stop();
await terminal.flush();
const viewport = terminal.getViewport();
expect(viewport.some((line) => line.includes("body-after"))).toBe(true);
expect(viewport.some((line) => line.includes("external application"))).toBe(false);
});
});
type ClearStatusContext = {
activeStatusIndicator: { kind: "working"; dispose: () => void } | undefined;
statusContainer: Container;
ui: { uiMode: "regular" | "fullscreen"; getClearOnShrink: () => boolean };
uiMode: "regular" | "fullscreen";
ui: { getClearOnShrink: () => boolean };
idleStatus: Component;
};
@ -167,15 +91,16 @@ const interactiveModePrototype = InteractiveMode.prototype as unknown as Interac
describe("clear-on-shrink status spacing", () => {
it("reserves status height only on the main-screen renderer", () => {
for (const [uiMode, expectedChildren] of [
["regular", 1],
["fullscreen", 0],
for (const [mode, expectedChildren] of [
["main", 1],
["alternate", 0],
] as const) {
const dispose = vi.fn();
const context: ClearStatusContext = {
activeStatusIndicator: { kind: "working", dispose },
statusContainer: new Container(),
ui: { uiMode, getClearOnShrink: () => true },
uiMode: mode === "main" ? "regular" : "fullscreen",
ui: { getClearOnShrink: () => true },
idleStatus: new Text("", 0, 0),
};

View file

@ -16,7 +16,7 @@ export class SwitchableTui extends TuiAltScreen {
private currentMode: TuiScreenMode;
private started = false;
private renderedMainScreen: boolean;
private mainViewportDirty = false;
private mainNeedsFullRedraw = false;
constructor(terminal: Terminal, options: SwitchableTuiOptions) {
super(terminal, options.showHardwareCursor, options.logDirectory, options.altScreen);
@ -32,12 +32,6 @@ export class SwitchableTui extends TuiAltScreen {
return this.currentMode;
}
private renderMainScreen(): void {
this.renderedMainScreen = true;
this.renderMainScreenImmediately(this.mainViewportDirty);
this.mainViewportDirty = false;
}
setMode(mode: TuiScreenMode): boolean {
if (mode === this.currentMode) return true;
if (this.hasOverlayEntries) return false;
@ -48,11 +42,16 @@ export class SwitchableTui extends TuiAltScreen {
return true;
}
const forceMainRedraw = mode === "main" && this.mainNeedsFullRedraw;
this.switchRenderer(() => {
this.currentMode = mode;
});
this.invalidate();
if (mode === "main") this.renderMainScreen();
if (mode === "main") {
this.renderedMainScreen = true;
if (forceMainRedraw) this.requestRender(true);
this.mainNeedsFullRedraw = false;
}
return true;
}
@ -60,7 +59,10 @@ export class SwitchableTui extends TuiAltScreen {
if (this.started) return;
super.start();
this.started = true;
if (this.currentMode === "main" && this.mainViewportDirty) this.renderMainScreen();
if (this.currentMode === "main" && this.mainNeedsFullRedraw) {
this.requestRender(true);
this.mainNeedsFullRedraw = false;
}
}
override stop(): void {
@ -72,16 +74,13 @@ export class SwitchableTui extends TuiAltScreen {
this.currentMode = "main";
}, false);
this.invalidate();
this.renderMainScreen();
this.renderImmediately(this.mainNeedsFullRedraw);
}
super.stop();
} finally {
try {
super.stop();
} finally {
this.currentMode = mode;
if (mode === "alternate" && this.renderedMainScreen) this.mainViewportDirty = true;
this.started = false;
}
this.currentMode = mode;
if (mode === "alternate" && this.renderedMainScreen) this.mainNeedsFullRedraw = true;
this.started = false;
}
}
}

View file

@ -150,7 +150,6 @@ export class TuiAltScreen extends TuiMainScreen implements ViewportTUI {
if (this.layoutRoot === component) return;
this.layoutRoot = component;
this.currentLayout = undefined;
this.invalidate();
this.requestRender();
}

View file

@ -1,6 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { cropKittyImageLine, deleteKittyImage, getKittyImageMetadata, isImageLine } from "./terminal-image.ts";
import { deleteKittyImage, isImageLine } from "./terminal-image.ts";
import { type TUI, TuiBase } from "./tui.ts";
import { visibleWidth } from "./utils.ts";
@ -143,103 +143,6 @@ export class TuiMainScreen extends TuiBase implements TUI {
return this.deleteKittyImages(ids);
}
private renderLines(
width: number,
height: number,
): { lines: string[]; cursorPos: { row: number; col: number } | null } {
let lines = this.render(width);
if (this.hasOverlayEntries) lines = this.compositeOverlays(lines, width, height);
const cursorPos = this.extractCursorPosition(lines, height);
return { lines: this.applyLineResets(lines), cursorPos };
}
private renderLineRange(lines: string[], start: number, end: number): string {
let buffer = "";
for (let i = start; i < end; i++) {
if (i > start) buffer += "\r\n";
const line = lines[i]!;
const isImage = isImageLine(line);
const imageReservedRows = isImage ? this.getKittyImageReservedRows(lines, i, end - 1) : 1;
if (imageReservedRows > 1 && imageReservedRows <= this.terminal.rows) {
buffer += "\r\n".repeat(imageReservedRows - 1);
buffer += `\x1b[${imageReservedRows - 1}A${line}\x1b[${imageReservedRows - 1}B`;
i += imageReservedRows - 1;
continue;
}
buffer += line;
}
return buffer;
}
private getViewportLines(lines: string[], height: number, viewportTop: number): string[] {
const viewportLines = lines.slice(viewportTop, viewportTop + height);
if (viewportTop === 0 || viewportLines.length === 0) return viewportLines;
for (let imageRow = viewportTop - 1; imageRow >= 0; imageRow--) {
const imageLine = lines[imageRow]!;
const metadata = getKittyImageMetadata(imageLine);
if (metadata) {
const hiddenRows = viewportTop - imageRow;
const reservedRows = this.getKittyImageReservedRows(lines, imageRow);
if (hiddenRows < reservedRows) {
const visibleRows = Math.min(reservedRows - hiddenRows, viewportLines.length);
viewportLines[0] = cropKittyImageLine(imageLine, hiddenRows, visibleRows);
}
break;
}
if (isImageLine(imageLine) || visibleWidth(imageLine) > 0) break;
}
return viewportLines;
}
private repaintVisibleKittyImages(): void {
const viewportLines = this.getViewportLines(this.previousLines, this.terminal.rows, this.previousViewportTop);
let placements = "";
let cursorRow = this.hardwareCursorRow;
for (let row = 0; row < viewportLines.length; row++) {
const line = viewportLines[row]!;
if (extractKittyImageIds(line).length === 0) continue;
const targetRow = this.previousViewportTop + row;
const rowDelta = targetRow - cursorRow;
if (rowDelta > 0) placements += `\x1b[${rowDelta}B`;
else if (rowDelta < 0) placements += `\x1b[${-rowDelta}A`;
placements += `\r${line}`;
cursorRow = targetRow;
}
if (placements) this.terminal.write(`\x1b[?2026h\x1b7${placements}\x1b8\x1b[?2026l`);
}
/** Update the main screen, recovering only its viewport when external output may have replaced it. */
protected renderMainScreenImmediately(viewportDirty: boolean): void {
if (!viewportDirty) {
this.renderImmediately();
this.repaintVisibleKittyImages();
return;
}
this.renderImmediately(false, () => {
if (this.stopped) return;
const width = this.terminal.columns;
const height = this.terminal.rows;
const { lines, cursorPos } = this.renderLines(width, height);
const viewportTop = Math.max(0, lines.length - height);
const viewportLines = this.getViewportLines(lines, height, viewportTop);
this.fullRedrawCount += 1;
this.terminal.write(
`\x1b[?2026h${this.deleteKittyImages(this.previousKittyImageIds)}\x1b[2J\x1b[H${this.renderLineRange(viewportLines, 0, viewportLines.length)}\x1b[?2026l`,
);
this.cursorRow = Math.max(0, lines.length - 1);
this.hardwareCursorRow = this.cursorRow;
this.maxLinesRendered = lines.length;
this.previousViewportTop = viewportTop;
this.positionHardwareCursor(cursorPos, lines.length);
this.previousLines = lines;
this.previousKittyImageIds = this.collectKittyImageIds(lines);
this.previousWidth = width;
this.previousHeight = height;
});
}
protected doRender(): void {
if (this.stopped) return;
const width = this.terminal.columns;
@ -256,7 +159,18 @@ export class TuiMainScreen extends TuiBase implements TUI {
return targetScreenRow - currentScreenRow;
};
const { lines: newLines, cursorPos } = this.renderLines(width, height);
// Render all components to get new lines
let newLines = this.render(width);
// Composite overlays into the rendered lines (before differential compare)
if (this.hasOverlayEntries) {
newLines = this.compositeOverlays(newLines, width, height);
}
// Extract cursor position before applying line resets (marker must be found first)
const cursorPos = this.extractCursorPosition(newLines, height);
newLines = this.applyLineResets(newLines);
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
@ -266,7 +180,23 @@ export class TuiMainScreen extends TuiBase implements TUI {
buffer += this.deleteKittyImages(this.previousKittyImageIds);
buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
}
buffer += this.renderLineRange(newLines, 0, newLines.length);
for (let i = 0; i < newLines.length; i++) {
if (i > 0) buffer += "\r\n";
const line = newLines[i];
const isImage = isImageLine(line);
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
if (imageReservedRows > 1 && imageReservedRows <= height) {
for (let row = 1; row < imageReservedRows; row++) {
buffer += "\r\n";
}
buffer += `\x1b[${imageReservedRows - 1}A`;
buffer += line;
buffer += `\x1b[${imageReservedRows - 1}B`;
i += imageReservedRows - 1;
continue;
}
buffer += line;
}
buffer += "\x1b[?2026l"; // End synchronized output
this.terminal.write(buffer);
this.cursorRow = Math.max(0, newLines.length - 1);

View file

@ -678,9 +678,14 @@ export abstract class TuiBase extends Container implements TUI {
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
}
private activateRenderer(startTerminal: () => void): void {
private activateRenderer(resumeTerminal = false): void {
this.beforeTerminalStart();
startTerminal();
if (resumeTerminal) this.terminal.resumeRenderer!();
else
this.terminal.start(
(data) => this.handleTerminalInput(data),
() => this.requestRender(),
);
this.afterTerminalStart();
this.terminal.hideCursor();
if (this.terminalColorSchemeNotificationsEnabled) this.terminal.write("\x1b[?2031h");
@ -688,22 +693,18 @@ export abstract class TuiBase extends Container implements TUI {
this.requestRender();
}
private deactivateRenderer(stopTerminal: () => void): void {
private deactivateRenderer(suspendTerminal = false): void {
if (this.terminalColorSchemeNotificationsEnabled) this.terminal.write("\x1b[?2031l");
this.beforeTerminalStop();
this.terminal.showCursor();
stopTerminal();
if (suspendTerminal) this.terminal.suspendRenderer!();
else this.terminal.stop();
this.afterTerminalStop();
}
start(): void {
this.stopped = false;
this.activateRenderer(() => {
this.terminal.start(
(data) => this.handleTerminalInput(data),
() => this.requestRender(),
);
});
this.activateRenderer();
}
addInputListener(listener: TuiInputListener): () => void {
@ -746,60 +747,43 @@ export abstract class TuiBase extends Container implements TUI {
/** Change renderer-specific screen modes while retaining this TUI instance. */
protected switchRenderer(switchMode: () => void, resumeTerminal = true): void {
const canSuspend = this.terminal.suspendRenderer !== undefined && this.terminal.resumeRenderer !== undefined;
const suspendTerminal = this.terminal.suspendRenderer !== undefined && this.terminal.resumeRenderer !== undefined;
this.rendererSwitchStop = true;
try {
this.deactivateRenderer(() => {
if (canSuspend) this.terminal.suspendRenderer?.();
else this.terminal.stop();
});
this.deactivateRenderer(suspendTerminal);
} finally {
this.rendererSwitchStop = false;
}
switchMode();
if (!resumeTerminal && canSuspend) return;
this.activateRenderer(() => {
if (canSuspend) {
this.terminal.resumeRenderer?.();
} else {
this.terminal.start(
(data) => this.handleTerminalInput(data),
() => this.requestRender(),
);
}
});
if (!resumeTerminal && suspendTerminal) return;
this.activateRenderer(suspendTerminal);
}
protected renderImmediately(force = false, render: () => void = () => this.doRender()): void {
if (force) this.resetRenderState();
private clearPendingRender(): void {
this.renderRequested = false;
if (this.renderTimer) {
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}
if (!this.renderTimer) return;
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}
protected renderImmediately(force = false): void {
if (force) this.resetRenderState();
this.clearPendingRender();
this.lastRenderAt = performance.now();
render();
this.doRender();
}
stop(): void {
this.stopped = true;
this.renderRequested = false;
if (this.renderTimer) {
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}
this.deactivateRenderer(() => this.terminal.stop());
this.clearPendingRender();
this.deactivateRenderer();
}
requestRender(force = false): void {
if (force) {
this.resetRenderState();
if (this.renderTimer) {
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}
this.clearPendingRender();
this.renderRequested = true;
process.nextTick(() => {
if (this.stopped || !this.renderRequested) {

View file

@ -1,244 +0,0 @@
import assert from "node:assert";
import { afterEach, describe, it } from "node:test";
import { Image } from "../src/components/image.ts";
import { SwitchableTui } from "../src/switchable-tui.ts";
import type { TerminalCapabilities } from "../src/terminal-image.ts";
import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.ts";
import type { Component } from "../src/tui.ts";
import { VirtualTerminal } from "./virtual-terminal.ts";
const NO_IMAGES: TerminalCapabilities = { images: null, trueColor: true, hyperlinks: true };
class RecordingTerminal extends VirtualTerminal {
readonly writes: string[] = [];
startCount = 0;
stopCount = 0;
active = false;
override start(onInput: (data: string) => void, onResize: () => void): void {
this.startCount += 1;
this.active = true;
super.start(onInput, onResize);
}
suspendRenderer(): void {}
resumeRenderer(): void {}
override stop(): void {
this.stopCount += 1;
this.active = false;
super.stop();
}
override write(data: string): void {
this.writes.push(data);
super.write(data);
}
}
afterEach(() => resetCapabilitiesCache());
describe("SwitchableTui", () => {
it("cleans up the terminal when final main-screen rendering throws", async () => {
setCapabilities(NO_IMAGES);
const terminal = new RecordingTerminal(40, 8);
const tui = new SwitchableTui(terminal, { mode: "main" });
let throwOnRender = false;
tui.addChild({
render: () => {
if (throwOnRender) throw new Error("extension render failed");
return ["content"];
},
invalidate: () => {},
});
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.setMode("alternate"), true);
await terminal.waitForRender();
throwOnRender = true;
assert.throws(() => tui.stop(), /extension render failed/);
assert.strictEqual(terminal.active, false);
assert.strictEqual(terminal.stopCount, 1);
tui.stop();
assert.strictEqual(terminal.stopCount, 1);
});
it("recovers the main viewport without clearing scrollback", async () => {
setCapabilities(NO_IMAGES);
const terminal = new RecordingTerminal(40, 4);
for (let index = 1; index <= 12; index++) terminal.write(`history ${index}\r\n`);
await terminal.flush();
const tui = new SwitchableTui(terminal, { mode: "main" });
tui.addChild({ render: () => ["header", "body", "editor"], invalidate: () => {} });
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.setMode("alternate"), true);
await terminal.waitForRender();
tui.stop();
tui.start();
await terminal.waitForRender();
terminal.writes.length = 0;
tui.stop();
await terminal.flush();
const output = terminal.writes.join("");
assert.ok(output.includes("\x1b[2J\x1b[H"), "recovery should clear the visible viewport");
assert.ok(!output.includes("\x1b[3J"), "recovery must not clear terminal scrollback");
assert.ok(terminal.getScrollBuffer().some((line) => line.includes("history 1")));
});
it("invalidates image placeholders after restoring iTerm2 capabilities", async () => {
setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true });
const terminal = new RecordingTerminal(40, 8);
const tui = new SwitchableTui(terminal, { mode: "main" });
const image = new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ filename: "example.png" },
{ widthPx: 10, heightPx: 10 },
);
tui.addChild(image);
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.setMode("alternate"), true);
await terminal.waitForRender();
assert.strictEqual(tui.setMode("main"), true);
await terminal.waitForRender();
const renderedImage = image.render(terminal.columns).join("");
assert.ok(renderedImage.includes("\x1b]1337;File="));
assert.ok(!renderedImage.includes("[Image:"));
tui.stop();
});
it("repaints Kitty images after returning to the main screen", async () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
const terminal = new RecordingTerminal(20, 6);
const tui = new SwitchableTui(terminal, { mode: "main" });
tui.addChild(
new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ maxWidthCells: 1, maxHeightCells: 2 },
{ widthPx: 9, heightPx: 36 },
),
);
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.setMode("alternate"), true);
await terminal.waitForRender();
terminal.writes.length = 0;
assert.strictEqual(tui.setMode("main"), true);
await terminal.waitForRender();
const output = terminal.writes.join("");
const deleteIndex = output.indexOf("\x1b_Ga=d,d=A");
const transmitIndex = output.lastIndexOf("\x1b_Ga=T");
assert.ok(deleteIndex >= 0);
assert.ok(transmitIndex > deleteIndex, "main-screen image must be retransmitted after Kitty deletes it");
tui.stop();
});
it("does not repaint Kitty images above a preserved main-screen viewport", async () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
const terminal = new RecordingTerminal(20, 3);
const tui = new SwitchableTui(terminal, { mode: "main" });
tui.setClearOnShrink(false);
tui.addChild({ render: () => ["prefix 1", "prefix 2", "prefix 3"], invalidate: () => {} });
tui.addChild(
new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ maxWidthCells: 1, maxHeightCells: 2 },
{ widthPx: 9, heightPx: 36 },
),
);
let tail = ["tail 1", "tail 2", "tail 3"];
tui.addChild({ render: () => tail, invalidate: () => {} });
tui.start();
await terminal.waitForRender();
tail = ["tail 1"];
tui.requestRender();
await terminal.waitForRender();
assert.strictEqual(tui.setMode("alternate"), true);
await terminal.waitForRender();
terminal.writes.length = 0;
assert.strictEqual(tui.setMode("main"), true);
await terminal.waitForRender();
const output = terminal.writes.join("");
assert.ok(output.includes("\x1b_Ga=d,d=A"));
assert.ok(!output.includes("\x1b_Ga=T"), "image above the tracked viewport must stay in scrollback");
tui.stop();
});
it("crops a Kitty image crossing the recovered viewport boundary", async () => {
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
const terminal = new RecordingTerminal(20, 3);
const tui = new SwitchableTui(terminal, { mode: "main" });
tui.addChild(
new Image(
"AAAA",
"image/png",
{ fallbackColor: (value) => value },
{ maxWidthCells: 1, maxHeightCells: 3 },
{ widthPx: 9, heightPx: 54 },
),
);
tui.addChild({ render: () => ["tail 1", "tail 2"], invalidate: () => {} });
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.setMode("alternate"), true);
await terminal.waitForRender();
tui.stop();
tui.start();
await terminal.waitForRender();
terminal.writes.length = 0;
tui.stop();
const output = terminal.writes.join("");
assert.ok(output.includes("y=36,h=18,r=1"), "visible image rows should be retransmitted as a crop");
assert.ok(!output.includes("\x1b[3J"));
});
it("invalidates an inactive layout root before activating it", async () => {
setCapabilities(NO_IMAGES);
const terminal = new RecordingTerminal(40, 8);
const tui = new SwitchableTui(terminal, { mode: "alternate" });
let value = "old fullscreen value";
let cachedLines: string[] | undefined;
const fullscreenRoot: Component = {
render: () => {
cachedLines ??= [value];
return cachedLines;
},
invalidate: () => {
cachedLines = undefined;
},
};
tui.setLayoutRoot(fullscreenRoot);
tui.addChild({ render: () => ["main value"], invalidate: () => {} });
tui.start();
await terminal.waitForRender();
assert.strictEqual(tui.setMode("main"), true);
await terminal.waitForRender();
value = "new fullscreen value";
tui.invalidate();
assert.strictEqual(tui.setMode("alternate"), true);
await terminal.waitForRender();
const viewport = terminal.getViewport().join("\n");
assert.ok(viewport.includes("new fullscreen value"));
assert.ok(!viewport.includes("old fullscreen value"));
tui.stop();
});
});