diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b2e8218f2..77d5cfa10 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-tui.ts b/packages/coding-agent/src/modes/interactive/interactive-tui.ts deleted file mode 100644 index 643acae45..000000000 --- a/packages/coding-agent/src/modes/interactive/interactive-tui.ts +++ /dev/null @@ -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); -} diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index c87594226..0b95c49f3 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -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), }; diff --git a/packages/tui/src/switchable-tui.ts b/packages/tui/src/switchable-tui.ts index a0f7e81a5..04afbaf89 100644 --- a/packages/tui/src/switchable-tui.ts +++ b/packages/tui/src/switchable-tui.ts @@ -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; } } } diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 292ac1565..555b671c7 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -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(); } diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index 5161523e4..adf33ffa0 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -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); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 94a6eee09..62f21ed66 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -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) { diff --git a/packages/tui/test/switchable-tui.test.ts b/packages/tui/test/switchable-tui.test.ts deleted file mode 100644 index 94d0058a4..000000000 --- a/packages/tui/test/switchable-tui.test.ts +++ /dev/null @@ -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(); - }); -});