mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui): reduce switchable renderer complexity
This commit is contained in:
parent
ccde8478dc
commit
25080b9d4c
8 changed files with 121 additions and 551 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue