mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): add switchable terminal renderers (#7440)
Allow coding-agent UI modes to switch at runtime while preserving terminal, focus, input, and renderer state.
This commit is contained in:
parent
aa0ec808b9
commit
1fdf21621d
16 changed files with 476 additions and 135 deletions
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
- Added chainable `pi.registerMarkdownTransformer()` hooks for display-only transformation of user and assistant Markdown.
|
||||
- Added an experimental fullscreen UI mode, selectable through `--ui-mode fullscreen` or `/settings` ([#7304](https://github.com/earendil-works/pi/issues/7304)).
|
||||
- Added runtime switching between regular and fullscreen UI modes through `/settings` without restarting pi.
|
||||
- Added a sticky editor, status, widget, and footer dock to fullscreen mode while keeping the transcript independently scrollable.
|
||||
- Added a draggable transcript scrollbar to fullscreen mode with configurable `auto`, `always`, and `hidden` modes through `/settings`; `always` reserves the rightmost column.
|
||||
- Added page scrolling and marked-message navigation shortcuts to fullscreen mode.
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
|
|||
| `outputPad` | number | `1` | Horizontal padding for user messages, assistant messages, and thinking (0 or 1) |
|
||||
| `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) |
|
||||
| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support |
|
||||
| `uiMode` | string | `"regular"` | Interactive UI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply after restart; `--ui-mode` overrides this setting for one run |
|
||||
| `uiMode` | string | `"regular"` | Interactive UI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--ui-mode` overrides this setting at startup |
|
||||
| `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling, `"always"` reserves the rightmost column and keeps it visible, and `"hidden"` hides it. Has no effect in regular UI mode |
|
||||
|
||||
For VS Code, include `--wait` so pi resumes after the editor exits:
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ pi --no-extensions -e ./my-extension.ts
|
|||
|
||||
In `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally.
|
||||
|
||||
To use fullscreen mode by default, set **UI mode** to `fullscreen` in `/settings`. The change applies after restarting pi.
|
||||
Set **UI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions.
|
||||
|
||||
### File Arguments
|
||||
|
||||
|
|
|
|||
|
|
@ -618,7 +618,7 @@ export class SettingsSelectorComponent extends Container {
|
|||
{
|
||||
id: "ui-mode",
|
||||
label: "UI mode",
|
||||
description: "Interface layout used after restart; fullscreen mode is experimental",
|
||||
description: "Interface layout; fullscreen mode is experimental",
|
||||
currentValue: config.uiMode,
|
||||
values: ["regular", "fullscreen"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import type {
|
|||
OverlayHandle,
|
||||
OverlayOptions,
|
||||
SlashCommand,
|
||||
Terminal,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import * as TuiLayouts from "@earendil-works/pi-tui";
|
||||
import {
|
||||
|
|
@ -32,14 +31,11 @@ import {
|
|||
hyperlink,
|
||||
Markdown,
|
||||
matchesKey,
|
||||
ProcessTerminal,
|
||||
Spacer,
|
||||
setKeybindings,
|
||||
Text,
|
||||
TruncatedText,
|
||||
type TUI,
|
||||
TuiAltScreen,
|
||||
TuiMainScreen,
|
||||
visibleWidth,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import chalk from "chalk";
|
||||
|
|
@ -102,7 +98,6 @@ 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";
|
||||
|
|
@ -150,6 +145,7 @@ 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,
|
||||
|
|
@ -329,25 +325,11 @@ export interface InteractiveModeOptions {
|
|||
uiMode?: UiMode;
|
||||
}
|
||||
|
||||
interface InteractiveTuiOptions {
|
||||
uiMode: UiMode;
|
||||
showHardwareCursor: boolean;
|
||||
logDirectory: string;
|
||||
terminal?: Terminal;
|
||||
}
|
||||
|
||||
/** Composition root for selecting the interactive terminal renderer. */
|
||||
export function createInteractiveTui(options: InteractiveTuiOptions): TUI {
|
||||
const terminal = options.terminal ?? new ProcessTerminal();
|
||||
if (options.uiMode === "fullscreen") {
|
||||
return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser });
|
||||
}
|
||||
return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
|
||||
}
|
||||
export { createInteractiveTui };
|
||||
|
||||
export class InteractiveMode {
|
||||
private runtimeHost: AgentSessionRuntime;
|
||||
private ui: TUI;
|
||||
private ui: InteractiveTui;
|
||||
private loadedResourcesContainer: Container;
|
||||
private chatContainer: Container;
|
||||
private documentContainer: Container;
|
||||
|
|
@ -743,39 +725,36 @@ export class InteractiveMode {
|
|||
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
|
||||
}
|
||||
|
||||
// Populate stable regions before selecting the renderer-specific composition.
|
||||
// Populate both compositions so the renderer can change without rebuilding component state.
|
||||
this.renderWidgets(); // Initialize with default spacer
|
||||
if (TuiLayouts.isViewportTUI(this.ui)) {
|
||||
this.transcriptScrollView = new TuiLayouts.ScrollView(this.documentContainer, {
|
||||
follow: "end",
|
||||
primary: true,
|
||||
overscroll: "chain",
|
||||
scrollbar: this.settingsManager.getFullscreenScrollbar(),
|
||||
scrollbarStyle: (text) => theme.bg("scrollbarThumb", text),
|
||||
});
|
||||
const dock = new TuiLayouts.VStack([
|
||||
{ component: this.pendingMessagesContainer, shrink: 1, minSize: 0 },
|
||||
{ component: this.statusContainer, shrink: 1, minSize: 0 },
|
||||
{ component: this.widgetContainerAbove, shrink: 1, minSize: 0 },
|
||||
{ component: this.editorContainer, shrink: 1, minSize: 3 },
|
||||
{ component: this.widgetContainerBelow, shrink: 1, minSize: 0 },
|
||||
{ component: this.footerContainer, shrink: 1, minSize: 1 },
|
||||
]);
|
||||
this.ui.setLayoutRoot(
|
||||
new TuiLayouts.VStack([
|
||||
{ component: this.transcriptScrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
||||
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
this.transcriptScrollView = new TuiLayouts.ScrollView(this.documentContainer, {
|
||||
follow: "end",
|
||||
primary: true,
|
||||
overscroll: "chain",
|
||||
scrollbar: this.settingsManager.getFullscreenScrollbar(),
|
||||
scrollbarStyle: (text) => theme.bg("scrollbarThumb", text),
|
||||
});
|
||||
const dock = new TuiLayouts.VStack([
|
||||
{ component: this.pendingMessagesContainer, shrink: 1, minSize: 0 },
|
||||
{ component: this.statusContainer, shrink: 1, minSize: 0 },
|
||||
{ component: this.widgetContainerAbove, shrink: 1, minSize: 0 },
|
||||
{ component: this.editorContainer, shrink: 1, minSize: 3 },
|
||||
{ component: this.widgetContainerBelow, shrink: 1, minSize: 0 },
|
||||
{ component: this.footerContainer, shrink: 1, minSize: 1 },
|
||||
]);
|
||||
this.ui.setLayoutRoot(
|
||||
new TuiLayouts.VStack([
|
||||
{ component: this.transcriptScrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
||||
{ 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);
|
||||
this.ui.setFocus(this.editor);
|
||||
|
||||
this.setupKeyHandlers();
|
||||
|
|
@ -1937,7 +1916,7 @@ export class InteractiveMode {
|
|||
this.activeStatusIndicator?.dispose();
|
||||
this.activeStatusIndicator = undefined;
|
||||
this.statusContainer.clear();
|
||||
if (hadActiveStatusIndicator && this.options.uiMode === "regular" && this.ui.getClearOnShrink()) {
|
||||
if (hadActiveStatusIndicator && this.ui.uiMode === "regular" && this.ui.getClearOnShrink()) {
|
||||
this.statusContainer.addChild(this.idleStatus);
|
||||
}
|
||||
}
|
||||
|
|
@ -4211,7 +4190,8 @@ export class InteractiveMode {
|
|||
|
||||
private showSettingsSelector(): void {
|
||||
this.showSelector((done) => {
|
||||
const selector = new SettingsSelectorComponent(
|
||||
let selector: SettingsSelectorComponent | undefined;
|
||||
selector = new SettingsSelectorComponent(
|
||||
{
|
||||
autoCompact: this.session.autoCompactionEnabled,
|
||||
showImages: this.settingsManager.getShowImages(),
|
||||
|
|
@ -4242,7 +4222,7 @@ export class InteractiveMode {
|
|||
quietStartup: this.settingsManager.getQuietStartup(),
|
||||
clearOnShrink: this.settingsManager.getClearOnShrink(),
|
||||
showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
|
||||
uiMode: this.settingsManager.getUiMode(),
|
||||
uiMode: this.ui.uiMode,
|
||||
fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
|
||||
warnings: this.settingsManager.getWarnings(),
|
||||
},
|
||||
|
|
@ -4385,8 +4365,14 @@ export class InteractiveMode {
|
|||
this.settingsManager.setShowTerminalProgress(enabled);
|
||||
},
|
||||
onUiModeChange: (mode) => {
|
||||
if (!this.ui.setUiMode(mode)) {
|
||||
selector?.getSettingsList().updateValue("ui-mode", this.ui.uiMode);
|
||||
this.showStatus("Close active overlays before changing UI mode");
|
||||
return;
|
||||
}
|
||||
this.settingsManager.setUiMode(mode);
|
||||
this.showStatus(`UI mode: ${mode} (restart required)`);
|
||||
if (!this.activeStatusIndicator) this.statusContainer.clear();
|
||||
this.showStatus(`UI mode: ${mode}`);
|
||||
},
|
||||
onFullscreenScrollbarChange: (mode) => {
|
||||
this.settingsManager.setFullscreenScrollbar(mode);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
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,48 +6,156 @@ 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");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
function createTui(terminal: Terminal, uiMode: "regular" | "fullscreen" = "regular") {
|
||||
return createInteractiveTui({ uiMode, showHardwareCursor: false, logDirectory: "/tmp", terminal });
|
||||
}
|
||||
|
||||
describe("createInteractiveTui", () => {
|
||||
it("selects the alternate-screen renderer only when requested", async () => {
|
||||
const mainTerminal = new RecordingTerminal();
|
||||
const mainTui = createInteractiveTui({
|
||||
uiMode: "regular",
|
||||
showHardwareCursor: false,
|
||||
logDirectory: "/tmp",
|
||||
terminal: mainTerminal,
|
||||
});
|
||||
expect(isViewportTUI(mainTui)).toBe(false);
|
||||
mainTui.start();
|
||||
await mainTerminal.waitForRender();
|
||||
expect(mainTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(false);
|
||||
mainTui.stop();
|
||||
for (const [uiMode, screen] of [
|
||||
["regular", "main"],
|
||||
["fullscreen", "alternate"],
|
||||
] 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();
|
||||
}
|
||||
});
|
||||
|
||||
const altTerminal = new RecordingTerminal();
|
||||
const altTui = createInteractiveTui({
|
||||
uiMode: "fullscreen",
|
||||
showHardwareCursor: false,
|
||||
logDirectory: "/tmp",
|
||||
terminal: altTerminal,
|
||||
});
|
||||
expect(isViewportTUI(altTui)).toBe(true);
|
||||
altTui.start();
|
||||
await altTerminal.waitForRender();
|
||||
expect(altTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(true);
|
||||
altTui.stop();
|
||||
it("switches renderers without restarting the terminal or losing focus", async () => {
|
||||
const terminal = new RecordingTerminal(30, 6);
|
||||
const tui = createTui(terminal);
|
||||
const inputs: string[] = [];
|
||||
let text = "regular content";
|
||||
const component: Component = {
|
||||
render: () => [text],
|
||||
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);
|
||||
overlay.hide();
|
||||
expect(tui.setUiMode("fullscreen")).toBe(true);
|
||||
await terminal.waitForRender();
|
||||
expect(terminal.suspendScreens).toEqual(["main"]);
|
||||
expect(terminal.resumeScreens).toEqual(["alternate"]);
|
||||
terminal.sendInput("b");
|
||||
|
||||
text = "updated in fullscreen";
|
||||
tui.requestRender();
|
||||
await terminal.waitForRender();
|
||||
expect(tui.setUiMode("regular")).toBe(true);
|
||||
await terminal.waitForRender();
|
||||
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"]);
|
||||
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;
|
||||
options: { uiMode?: "regular" | "fullscreen" };
|
||||
ui: { getClearOnShrink: () => boolean };
|
||||
ui: { uiMode: "regular" | "fullscreen"; getClearOnShrink: () => boolean };
|
||||
idleStatus: Component;
|
||||
};
|
||||
|
||||
|
|
@ -67,8 +175,7 @@ describe("clear-on-shrink status spacing", () => {
|
|||
const context: ClearStatusContext = {
|
||||
activeStatusIndicator: { kind: "working", dispose },
|
||||
statusContainer: new Container(),
|
||||
options: { uiMode },
|
||||
ui: { getClearOnShrink: () => true },
|
||||
ui: { uiMode, getClearOnShrink: () => true },
|
||||
idleStatus: new Text("", 0, 0),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
- Exported the bundled `Marked` parser and token types.
|
||||
- Added width-aware source transforms to the `Markdown` component.
|
||||
- Added interface-compatible main-screen and alternate-screen TUI renderers with application-owned scrolling ([#7304](https://github.com/earendil-works/pi/issues/7304)).
|
||||
- Added `SwitchableTui` for switching between main-screen and alternate-screen renderers with stable TUI references, supported terminal lifecycle handoff, and no transcript replay.
|
||||
- Added alternate-screen `VStack`, `HStack`, and nested `ScrollView` layouts with constrained sizing, sticky regions, and pointer-targeted scrolling.
|
||||
- Added edge auto-scrolling for alternate-screen drag selection across off-screen scroll-view content.
|
||||
- Added proportional scrollbars with mouse dragging, Home/End document navigation, transient `auto` mode, and an `always` mode that reserves the rightmost column; scrollbar modes can be changed at runtime.
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ tui.start();
|
|||
|
||||
- `TuiMainScreen` renders into the main terminal buffer and preserves terminal scrollback.
|
||||
- `TuiAltScreen` renders a fixed-height viewport in the alternate terminal buffer with application-owned scrolling. When stopped, it restores the main buffer and prints the complete final document.
|
||||
- `SwitchableTui` keeps one stable TUI while switching between main-screen and alternate-screen rendering. `ProcessTerminal` preserves its terminal lifecycle across switches.
|
||||
|
||||
```typescript
|
||||
import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui";
|
||||
|
|
@ -78,6 +79,19 @@ tui.requestRender(); // Request a re-render
|
|||
tui.onDebug = () => console.log("Debug triggered");
|
||||
```
|
||||
|
||||
Use `SwitchableTui` when the renderer must change without replacing references retained by components. `setMode()` returns `false` while an overlay is mounted. Custom terminals can implement the optional `suspendRenderer()` and `resumeRenderer()` hooks to preserve their lifecycle while moving screen-local modes; otherwise `SwitchableTui` stops and restarts them during a switch.
|
||||
|
||||
```typescript
|
||||
import { SwitchableTui } from "@earendil-works/pi-tui";
|
||||
|
||||
const tui = new SwitchableTui(terminal, { mode: "main" });
|
||||
tui.setLayoutRoot(fullscreenLayout);
|
||||
tui.start();
|
||||
|
||||
tui.setMode("alternate");
|
||||
tui.setMode("main");
|
||||
```
|
||||
|
||||
### Alternate-screen viewport layouts
|
||||
|
||||
`TuiAltScreen` can render an explicit terminal-height layout. `VStack` and `HStack` allocate constrained regions, while `ScrollView` owns scrolling for one region. These semantics are intentionally unavailable on `TuiMainScreen`, where the terminal owns scrollback.
|
||||
|
|
@ -669,6 +683,8 @@ The TUI works with any object implementing the `Terminal` interface:
|
|||
interface Terminal {
|
||||
start(onInput: (data: string) => void, onResize: () => void): void;
|
||||
stop(): void;
|
||||
suspendRenderer?(): void;
|
||||
resumeRenderer?(): void;
|
||||
write(data: string): void;
|
||||
get columns(): number;
|
||||
get rows(): number;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export {
|
|||
} from "./keys.ts";
|
||||
// Input buffering for batch splitting
|
||||
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
|
||||
export { SwitchableTui, type SwitchableTuiOptions, type TuiScreenMode } from "./switchable-tui.ts";
|
||||
// Terminal interface and implementations
|
||||
export { ProcessTerminal, type Terminal } from "./terminal.ts";
|
||||
// Terminal colors
|
||||
|
|
|
|||
83
packages/tui/src/switchable-tui.ts
Normal file
83
packages/tui/src/switchable-tui.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { Terminal } from "./terminal.ts";
|
||||
import type { TuiAltScreenOptions } from "./tui-alt-screen.ts";
|
||||
import { TuiAltScreen } from "./tui-alt-screen.ts";
|
||||
|
||||
export type TuiScreenMode = "main" | "alternate";
|
||||
|
||||
export interface SwitchableTuiOptions {
|
||||
mode: TuiScreenMode;
|
||||
showHardwareCursor?: boolean;
|
||||
logDirectory?: string;
|
||||
altScreen?: TuiAltScreenOptions;
|
||||
}
|
||||
|
||||
/** TUI that can switch between main-screen and alternate-screen rendering. */
|
||||
export class SwitchableTui extends TuiAltScreen {
|
||||
private currentMode: TuiScreenMode;
|
||||
private started = false;
|
||||
private renderedMainScreen: boolean;
|
||||
private mainNeedsFullRedraw = false;
|
||||
|
||||
constructor(terminal: Terminal, options: SwitchableTuiOptions) {
|
||||
super(terminal, options.showHardwareCursor, options.logDirectory, options.altScreen);
|
||||
this.currentMode = options.mode;
|
||||
this.renderedMainScreen = options.mode === "main";
|
||||
}
|
||||
|
||||
protected override get alternateScreenEnabled(): boolean {
|
||||
return this.currentMode === "alternate";
|
||||
}
|
||||
|
||||
get mode(): TuiScreenMode {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
setMode(mode: TuiScreenMode): boolean {
|
||||
if (mode === this.currentMode) return true;
|
||||
if (this.hasOverlayEntries) return false;
|
||||
if (!this.started) {
|
||||
this.currentMode = mode;
|
||||
if (mode === "main") this.renderedMainScreen = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const forceMainRedraw = mode === "main" && this.mainNeedsFullRedraw;
|
||||
this.switchRenderer(() => {
|
||||
this.currentMode = mode;
|
||||
});
|
||||
if (mode === "main") {
|
||||
this.renderedMainScreen = true;
|
||||
if (forceMainRedraw) this.requestRender(true);
|
||||
this.mainNeedsFullRedraw = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
override start(): void {
|
||||
if (this.started) return;
|
||||
super.start();
|
||||
this.started = true;
|
||||
if (this.currentMode === "main" && this.mainNeedsFullRedraw) {
|
||||
this.requestRender(true);
|
||||
this.mainNeedsFullRedraw = false;
|
||||
}
|
||||
}
|
||||
|
||||
override stop(): void {
|
||||
if (!this.started) return;
|
||||
const mode = this.currentMode;
|
||||
try {
|
||||
if (mode === "alternate" && this.renderedMainScreen) {
|
||||
this.switchRenderer(() => {
|
||||
this.currentMode = "main";
|
||||
}, false);
|
||||
this.renderImmediately(this.mainNeedsFullRedraw);
|
||||
}
|
||||
super.stop();
|
||||
} finally {
|
||||
this.currentMode = mode;
|
||||
if (mode === "alternate" && this.renderedMainScreen) this.mainNeedsFullRedraw = true;
|
||||
this.started = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,12 @@ export interface Terminal {
|
|||
// Stop the terminal and restore state
|
||||
stop(): void;
|
||||
|
||||
/** Temporarily leave renderer-local terminal modes before changing screen buffers. */
|
||||
suspendRenderer?(): void;
|
||||
|
||||
/** Restore renderer-local terminal modes after changing screen buffers. */
|
||||
resumeRenderer?(): void;
|
||||
|
||||
/**
|
||||
* Drain stdin before exiting to prevent Kitty key release events from
|
||||
* leaking to the parent shell over slow SSH connections.
|
||||
|
|
@ -220,11 +226,25 @@ export class ProcessTerminal implements Terminal {
|
|||
private queryAndEnableKittyProtocol(): void {
|
||||
this.setupStdinBuffer();
|
||||
process.stdin.on("data", this.stdinDataHandler!);
|
||||
this.pushKeyboardProtocol();
|
||||
}
|
||||
|
||||
private pushKeyboardProtocol(): void {
|
||||
this.keyboardProtocolPushed = true;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY);
|
||||
}
|
||||
|
||||
private popKeyboardProtocol(): void {
|
||||
const shouldDisable = this.keyboardProtocolPushed || this._kittyProtocolActive;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
if (!shouldDisable) return;
|
||||
process.stdout.write("\x1b[<u");
|
||||
this.keyboardProtocolPushed = false;
|
||||
this._kittyProtocolActive = false;
|
||||
setKittyProtocolActive(false);
|
||||
}
|
||||
|
||||
private handleKeyboardProtocolNegotiationSequence(
|
||||
negotiationSequence: KeyboardProtocolNegotiationSequence | undefined,
|
||||
): boolean {
|
||||
|
|
@ -365,17 +385,21 @@ export class ProcessTerminal implements Terminal {
|
|||
}
|
||||
}
|
||||
|
||||
suspendRenderer(): void {
|
||||
process.stdout.write("\x1b[?2004l");
|
||||
this.popKeyboardProtocol();
|
||||
this.disableModifyOtherKeys();
|
||||
}
|
||||
|
||||
resumeRenderer(): void {
|
||||
process.stdout.write("\x1b[?2004h");
|
||||
this.pushKeyboardProtocol();
|
||||
}
|
||||
|
||||
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
|
||||
const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
if (shouldDisableKittyProtocol) {
|
||||
// Disable Kitty keyboard protocol first so any late key releases
|
||||
// do not generate new Kitty escape sequences.
|
||||
process.stdout.write("\x1b[<u");
|
||||
this.keyboardProtocolPushed = false;
|
||||
this._kittyProtocolActive = false;
|
||||
setKittyProtocolActive(false);
|
||||
}
|
||||
// Disable Kitty keyboard protocol first so any late key releases
|
||||
// do not generate new Kitty escape sequences.
|
||||
this.popKeyboardProtocol();
|
||||
this.disableModifyOtherKeys();
|
||||
|
||||
const previousHandler = this.inputHandler;
|
||||
|
|
@ -408,20 +432,7 @@ export class ProcessTerminal implements Terminal {
|
|||
process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
||||
}
|
||||
|
||||
// Disable bracketed paste mode
|
||||
process.stdout.write("\x1b[?2004l");
|
||||
|
||||
const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
|
||||
// Disable Kitty keyboard protocol if not already done by drainInput()
|
||||
if (shouldDisableKittyProtocol) {
|
||||
process.stdout.write("\x1b[<u");
|
||||
this.keyboardProtocolPushed = false;
|
||||
this._kittyProtocolActive = false;
|
||||
setKittyProtocolActive(false);
|
||||
}
|
||||
this.disableModifyOtherKeys();
|
||||
this.suspendRenderer();
|
||||
|
||||
// Clean up StdinBuffer
|
||||
if (this.stdinBuffer) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import {
|
|||
setCapabilities,
|
||||
type TerminalCapabilities,
|
||||
} from "./terminal-image.ts";
|
||||
import { type Component, CURSOR_MARKER, compositeTuiLine, TuiBase, VIEWPORT_TUI, type ViewportTUI } from "./tui.ts";
|
||||
import { type Component, CURSOR_MARKER, compositeTuiLine, VIEWPORT_TUI, type ViewportTUI } from "./tui.ts";
|
||||
import { TuiMainScreen } from "./tui-main-screen.ts";
|
||||
import {
|
||||
extractAnsiCode,
|
||||
getGraphemeCellRange,
|
||||
|
|
@ -82,8 +83,15 @@ export interface TuiAltScreenOptions {
|
|||
}
|
||||
|
||||
/** Alternate-screen TUI with a scrollable, application-owned viewport. */
|
||||
export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
readonly [VIEWPORT_TUI] = true as const;
|
||||
export class TuiAltScreen extends TuiMainScreen implements ViewportTUI {
|
||||
protected get alternateScreenEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
get [VIEWPORT_TUI](): true {
|
||||
// Switchable subclasses are not viewport TUIs while using the main screen.
|
||||
return this.alternateScreenEnabled ? true : (undefined as never);
|
||||
}
|
||||
private previousScreen: string[] = [];
|
||||
private lastDocument: string[] = [];
|
||||
private previousScreenWidth = 0;
|
||||
|
|
@ -128,7 +136,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
|
||||
this.mouseEnabled = options.mouse ?? true;
|
||||
this.openUrl = options.openUrl;
|
||||
this.addInputListener((data) => this.handleViewportInput(data));
|
||||
this.addInputListener((data) => (this.alternateScreenEnabled ? this.handleViewportInput(data) : undefined));
|
||||
}
|
||||
|
||||
get viewportTop(): number {
|
||||
|
|
@ -147,16 +155,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
return this.layoutRoot?.render(width) ?? super.render(width);
|
||||
return this.alternateScreenEnabled && this.layoutRoot ? this.layoutRoot.render(width) : super.render(width);
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
this.layoutRoot?.invalidate();
|
||||
if (this.alternateScreenEnabled) this.layoutRoot?.invalidate();
|
||||
}
|
||||
|
||||
protected override getMountedRoots(): readonly Component[] {
|
||||
return this.layoutRoot ? [this.layoutRoot] : this.children;
|
||||
return this.alternateScreenEnabled && this.layoutRoot ? [this.layoutRoot] : this.children;
|
||||
}
|
||||
|
||||
private getPrimaryScrollView(): ScrollView {
|
||||
|
|
@ -164,6 +172,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
}
|
||||
|
||||
protected override beforeTerminalStart(): void {
|
||||
if (!this.alternateScreenEnabled) {
|
||||
super.beforeTerminalStart();
|
||||
return;
|
||||
}
|
||||
this.stopSelectionAutoScroll();
|
||||
this.selectionPressActive = false;
|
||||
this.stopScrollbarHover();
|
||||
|
|
@ -189,6 +201,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
}
|
||||
|
||||
protected override beforeTerminalStop(): void {
|
||||
if (!this.alternateScreenEnabled) {
|
||||
super.beforeTerminalStop();
|
||||
return;
|
||||
}
|
||||
this.stopSelectionAutoScroll();
|
||||
this.selectionPressActive = false;
|
||||
this.stopScrollbarHover();
|
||||
|
|
@ -196,36 +212,48 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.flashes.dispose();
|
||||
if (!this.altScreenActive) return;
|
||||
this.terminal.write(
|
||||
`${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`,
|
||||
`${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteAltKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`,
|
||||
);
|
||||
}
|
||||
|
||||
protected override afterTerminalStop(): void {
|
||||
if (!this.alternateScreenEnabled) {
|
||||
super.afterTerminalStop();
|
||||
return;
|
||||
}
|
||||
if (!this.altScreenActive) return;
|
||||
this.altScreenActive = false;
|
||||
const width = Math.max(1, this.terminal.columns);
|
||||
const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
||||
this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(
|
||||
(line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),
|
||||
);
|
||||
let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`;
|
||||
for (let row = 0; row < this.lastDocument.length; row++) {
|
||||
if (row > 0) buffer += "\r\n";
|
||||
buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`;
|
||||
if (this.isRendererSwitchStop) {
|
||||
this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`);
|
||||
} else {
|
||||
const width = Math.max(1, this.terminal.columns);
|
||||
const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
||||
this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(
|
||||
(line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),
|
||||
);
|
||||
let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`;
|
||||
for (let row = 0; row < this.lastDocument.length; row++) {
|
||||
if (row > 0) buffer += "\r\n";
|
||||
buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`;
|
||||
}
|
||||
buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`;
|
||||
this.terminal.write(buffer);
|
||||
}
|
||||
buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`;
|
||||
this.terminal.write(buffer);
|
||||
if (this.savedCapabilities) {
|
||||
setCapabilities(this.savedCapabilities);
|
||||
this.savedCapabilities = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private deleteKittyImages(): string {
|
||||
private deleteAltKittyImages(): string {
|
||||
return this.imageProtocol === "kitty" ? deleteAllKittyImages() : "";
|
||||
}
|
||||
|
||||
protected override resetRenderState(): void {
|
||||
if (!this.alternateScreenEnabled) {
|
||||
super.resetRenderState();
|
||||
return;
|
||||
}
|
||||
this.previousScreen = [];
|
||||
this.previousScreenWidth = 0;
|
||||
this.previousScreenHeight = 0;
|
||||
|
|
@ -751,6 +779,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
}
|
||||
|
||||
protected override doRender(): void {
|
||||
if (!this.alternateScreenEnabled) {
|
||||
super.doRender();
|
||||
return;
|
||||
}
|
||||
if (this.stopped || !this.altScreenActive) return;
|
||||
const width = Math.max(1, this.terminal.columns);
|
||||
const height = Math.max(1, this.terminal.rows);
|
||||
|
|
@ -778,9 +810,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
let buffer = BEGIN_SYNCHRONIZED_OUTPUT;
|
||||
if (fullRedraw) {
|
||||
this.fullRedrawCount += 1;
|
||||
buffer += `${this.deleteKittyImages()}\x1b[2J`;
|
||||
buffer += `${this.deleteAltKittyImages()}\x1b[2J`;
|
||||
} else if (imagesNeedRedraw) {
|
||||
buffer += this.imageProtocol === "iterm2" ? "\x1b[2J" : this.deleteKittyImages();
|
||||
buffer += this.imageProtocol === "iterm2" ? "\x1b[2J" : this.deleteAltKittyImages();
|
||||
}
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
|
|||
}
|
||||
|
||||
protected override beforeTerminalStop(): void {
|
||||
if (this.previousLines.length === 0) return;
|
||||
if (this.isRendererSwitchStop || this.previousLines.length === 0) return;
|
||||
this.terminal.write(" ");
|
||||
const targetRow = this.previousLines.length;
|
||||
const lineDiff = targetRow - this.hardwareCursorRow;
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1";
|
||||
protected fullRedrawCount = 0;
|
||||
protected stopped = false;
|
||||
private rendererSwitchStop = false;
|
||||
private pendingOsc11BackgroundReplies = 0;
|
||||
private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = [];
|
||||
private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>();
|
||||
|
|
@ -370,6 +371,10 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
|
||||
protected afterTerminalStop(): void {}
|
||||
|
||||
protected get isRendererSwitchStop(): boolean {
|
||||
return this.rendererSwitchStop;
|
||||
}
|
||||
|
||||
get fullRedraws(): number {
|
||||
return this.fullRedrawCount;
|
||||
}
|
||||
|
|
@ -727,8 +732,54 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
this.terminal.write("\x1b[16t");
|
||||
}
|
||||
|
||||
/** 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;
|
||||
if (this.terminalColorSchemeNotificationsEnabled) this.terminal.write("\x1b[?2031l");
|
||||
this.rendererSwitchStop = true;
|
||||
try {
|
||||
this.beforeTerminalStop();
|
||||
this.terminal.showCursor();
|
||||
if (canSuspend) this.terminal.suspendRenderer?.();
|
||||
else this.terminal.stop();
|
||||
this.afterTerminalStop();
|
||||
} finally {
|
||||
this.rendererSwitchStop = false;
|
||||
}
|
||||
|
||||
switchMode();
|
||||
if (!resumeTerminal && canSuspend) return;
|
||||
|
||||
this.beforeTerminalStart();
|
||||
if (canSuspend) {
|
||||
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");
|
||||
this.queryCellSize();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
protected renderImmediately(force = false): void {
|
||||
if (force) this.resetRenderState();
|
||||
this.renderRequested = false;
|
||||
if (this.renderTimer) {
|
||||
clearTimeout(this.renderTimer);
|
||||
this.renderTimer = undefined;
|
||||
}
|
||||
this.lastRenderAt = performance.now();
|
||||
this.doRender();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.renderRequested = false;
|
||||
if (this.renderTimer) {
|
||||
clearTimeout(this.renderTimer);
|
||||
this.renderTimer = undefined;
|
||||
|
|
|
|||
|
|
@ -111,6 +111,24 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("moves keyboard modes between renderer screen buffers", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
harness.send("\x1b[?7u");
|
||||
harness.terminal.suspendRenderer();
|
||||
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[<u").length, 1);
|
||||
assert.equal(harness.writes.includes("\x1b[?2004l"), true);
|
||||
|
||||
harness.terminal.resumeRenderer();
|
||||
|
||||
assert.equal(harness.writes.filter((write) => write === "\x1b[>7u\x1b[?u\x1b[c").length, 2);
|
||||
assert.equal(harness.writes.includes("\x1b[?2004h"), true);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to modifyOtherKeys for zero Kitty flags", () => {
|
||||
const harness = setupNegotiation();
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue