mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(coding-agent): switch UI modes at runtime (#7555)
This commit is contained in:
parent
e563301dd3
commit
b103937d3c
13 changed files with 355 additions and 86 deletions
|
|
@ -11,6 +11,7 @@
|
|||
- Added built-in Baseten provider support with `BASETEN_API_KEY` authentication and `zai-org/GLM-5.2` as the default model.
|
||||
- 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`.
|
||||
- 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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
OverlayOptions,
|
||||
SlashCommand,
|
||||
Terminal,
|
||||
TuiMainScreenRenderState,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import * as TuiLayouts from "@earendil-works/pi-tui";
|
||||
import {
|
||||
|
|
@ -345,13 +346,39 @@ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScr
|
|||
return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
|
||||
}
|
||||
|
||||
/** Stable reference for components while InteractiveMode replaces the active renderer. */
|
||||
export function createInteractiveTuiReference(getTui: () => TUI): TUI {
|
||||
return new Proxy({} as TUI, {
|
||||
get: (_target, property) => {
|
||||
const tui = getTui();
|
||||
const value = Reflect.get(tui, property, tui);
|
||||
if (typeof value !== "function") return value;
|
||||
return (...args: unknown[]) => {
|
||||
const tui = getTui();
|
||||
const method = Reflect.get(tui, property, tui);
|
||||
if (typeof method !== "function") throw new TypeError(`TUI property ${String(property)} is not callable`);
|
||||
return Reflect.apply(method, tui, args);
|
||||
};
|
||||
},
|
||||
set: (_target, property, value) => {
|
||||
const tui = getTui();
|
||||
return Reflect.set(tui, property, value, tui);
|
||||
},
|
||||
has: (_target, property) => Reflect.has(getTui(), property),
|
||||
getPrototypeOf: () => Reflect.getPrototypeOf(getTui()),
|
||||
});
|
||||
}
|
||||
|
||||
export class InteractiveMode {
|
||||
private runtimeHost: AgentSessionRuntime;
|
||||
private ui: TuiMainScreen | TuiAltScreen;
|
||||
private renderer: TuiMainScreen | TuiAltScreen;
|
||||
private ui: TUI;
|
||||
private mainScreenRenderState: TuiMainScreenRenderState | undefined;
|
||||
private loadedResourcesContainer: Container;
|
||||
private chatContainer: Container;
|
||||
private documentContainer: Container;
|
||||
private transcriptScrollView: TuiLayouts.ScrollView | undefined;
|
||||
private fullscreenLayoutRoot: Component | undefined;
|
||||
private pendingMessagesContainer: Container;
|
||||
private statusContainer: Container;
|
||||
private defaultEditor: CustomEditor;
|
||||
|
|
@ -435,7 +462,10 @@ export class InteractiveMode {
|
|||
private extensionSelector: ExtensionSelectorComponent | undefined = undefined;
|
||||
private extensionInput: ExtensionInputComponent | undefined = undefined;
|
||||
private extensionEditor: ExtensionEditorComponent | undefined = undefined;
|
||||
private extensionTerminalInputUnsubscribers = new Set<() => void>();
|
||||
private extensionTerminalInputSubscriptions = new Set<{
|
||||
handler: (data: string) => { consume?: boolean; data?: string } | undefined;
|
||||
unsubscribe: () => void;
|
||||
}>();
|
||||
|
||||
// Extension widgets (components rendered above/below the editor)
|
||||
private extensionWidgetsAbove = new Map<string, Component & { dispose?(): void }>();
|
||||
|
|
@ -485,11 +515,12 @@ export class InteractiveMode {
|
|||
await this.rebindCurrentSession({ renderBeforeBind: true });
|
||||
});
|
||||
this.version = VERSION;
|
||||
this.ui = createInteractiveTui({
|
||||
this.renderer = createInteractiveTui({
|
||||
uiMode,
|
||||
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
|
||||
logDirectory: getAgentDir(),
|
||||
});
|
||||
this.ui = createInteractiveTuiReference(() => this.renderer);
|
||||
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
|
||||
this.headerContainer = new Container();
|
||||
this.loadedResourcesContainer = new Container();
|
||||
|
|
@ -715,6 +746,72 @@ export class InteractiveMode {
|
|||
this.chatContainer.addChild(new DynamicBorder());
|
||||
}
|
||||
|
||||
private mountInteractiveTui(tui: TuiMainScreen | TuiAltScreen, components: readonly Component[]): void {
|
||||
for (const component of components) tui.addChild(component);
|
||||
if (TuiLayouts.isViewportTUI(tui)) {
|
||||
if (!this.fullscreenLayoutRoot) throw new Error("Fullscreen layout is not initialized");
|
||||
tui.setLayoutRoot(this.fullscreenLayoutRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private stopInteractiveTui(): void {
|
||||
if (this.renderer.mode === "fullscreen") {
|
||||
while (this.renderer.hasOverlayEntries) this.renderer.hideOverlay();
|
||||
this.switchUiMode("regular", false);
|
||||
this.renderer.renderNow();
|
||||
}
|
||||
this.ui.stop();
|
||||
}
|
||||
|
||||
private switchUiMode(mode: UiMode, restoreProgress = true): boolean {
|
||||
const previousUi = this.renderer;
|
||||
if (mode === previousUi.mode) return true;
|
||||
if (previousUi.hasOverlayEntries) return false;
|
||||
|
||||
const components = [...previousUi.children];
|
||||
const focus = previousUi.getFocusedComponent();
|
||||
const terminal = previousUi.terminal;
|
||||
const showHardwareCursor = previousUi.getShowHardwareCursor();
|
||||
const clearOnShrink = previousUi.getClearOnShrink();
|
||||
const onDebug = previousUi.onDebug;
|
||||
if (previousUi instanceof TuiMainScreen) {
|
||||
this.mainScreenRenderState = previousUi.captureRenderState();
|
||||
}
|
||||
|
||||
previousUi.stop({ preserveScreen: true });
|
||||
previousUi.setFocus(null);
|
||||
previousUi.clear();
|
||||
if (TuiLayouts.isViewportTUI(previousUi)) previousUi.setLayoutRoot(undefined);
|
||||
|
||||
const nextUi = createInteractiveTui({
|
||||
uiMode: mode,
|
||||
showHardwareCursor,
|
||||
logDirectory: getAgentDir(),
|
||||
terminal,
|
||||
});
|
||||
nextUi.setClearOnShrink(clearOnShrink);
|
||||
nextUi.onDebug = onDebug;
|
||||
if (nextUi instanceof TuiMainScreen && this.mainScreenRenderState) {
|
||||
nextUi.restoreRenderState(this.mainScreenRenderState);
|
||||
}
|
||||
this.renderer = nextUi;
|
||||
this.options.uiMode = mode;
|
||||
this.mountInteractiveTui(nextUi, components);
|
||||
nextUi.invalidate();
|
||||
nextUi.setFocus(focus);
|
||||
nextUi.start();
|
||||
this.themeController.rebindTui();
|
||||
this.rebindExtensionTerminalInputListeners();
|
||||
if (
|
||||
restoreProgress &&
|
||||
this.settingsManager.getShowTerminalProgress() &&
|
||||
(this.session.isStreaming || this.session.isCompacting)
|
||||
) {
|
||||
terminal.setProgress(true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
|
|
@ -743,39 +840,36 @@ export class InteractiveMode {
|
|||
console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`));
|
||||
}
|
||||
|
||||
// Populate stable regions before selecting the renderer-specific composition.
|
||||
// Keep one component tree and remount it when changing renderers.
|
||||
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.fullscreenLayoutRoot = 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.mountInteractiveTui(this.renderer, [
|
||||
this.documentContainer,
|
||||
this.pendingMessagesContainer,
|
||||
this.statusContainer,
|
||||
this.widgetContainerAbove,
|
||||
this.editorContainer,
|
||||
this.widgetContainerBelow,
|
||||
this.footerContainer,
|
||||
]);
|
||||
this.ui.setFocus(this.editor);
|
||||
|
||||
this.setupKeyHandlers();
|
||||
|
|
@ -2182,19 +2276,24 @@ export class InteractiveMode {
|
|||
private addExtensionTerminalInputListener(
|
||||
handler: (data: string) => { consume?: boolean; data?: string } | undefined,
|
||||
): () => void {
|
||||
const unsubscribe = this.ui.addInputListener(handler);
|
||||
this.extensionTerminalInputUnsubscribers.add(unsubscribe);
|
||||
const subscription = { handler, unsubscribe: this.ui.addInputListener(handler) };
|
||||
this.extensionTerminalInputSubscriptions.add(subscription);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
this.extensionTerminalInputUnsubscribers.delete(unsubscribe);
|
||||
subscription.unsubscribe();
|
||||
this.extensionTerminalInputSubscriptions.delete(subscription);
|
||||
};
|
||||
}
|
||||
|
||||
private clearExtensionTerminalInputListeners(): void {
|
||||
for (const unsubscribe of this.extensionTerminalInputUnsubscribers) {
|
||||
unsubscribe();
|
||||
private rebindExtensionTerminalInputListeners(): void {
|
||||
for (const subscription of this.extensionTerminalInputSubscriptions) {
|
||||
subscription.unsubscribe();
|
||||
subscription.unsubscribe = this.ui.addInputListener(subscription.handler);
|
||||
}
|
||||
this.extensionTerminalInputUnsubscribers.clear();
|
||||
}
|
||||
|
||||
private clearExtensionTerminalInputListeners(): void {
|
||||
for (const subscription of this.extensionTerminalInputSubscriptions) subscription.unsubscribe();
|
||||
this.extensionTerminalInputSubscriptions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4211,7 +4310,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 +4342,7 @@ export class InteractiveMode {
|
|||
quietStartup: this.settingsManager.getQuietStartup(),
|
||||
clearOnShrink: this.settingsManager.getClearOnShrink(),
|
||||
showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
|
||||
uiMode: this.settingsManager.getUiMode(),
|
||||
uiMode: this.ui.mode,
|
||||
fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
|
||||
warnings: this.settingsManager.getWarnings(),
|
||||
},
|
||||
|
|
@ -4385,8 +4485,14 @@ export class InteractiveMode {
|
|||
this.settingsManager.setShowTerminalProgress(enabled);
|
||||
},
|
||||
onUiModeChange: (mode) => {
|
||||
if (!this.switchUiMode(mode)) {
|
||||
selector?.getSettingsList().updateValue("ui-mode", this.ui.mode);
|
||||
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);
|
||||
|
|
@ -5690,7 +5796,7 @@ export class InteractiveMode {
|
|||
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
if (options.flashConfirmation && this.ui.mode === "fullscreen") {
|
||||
if (options.flashConfirmation && this.ui instanceof TuiAltScreen) {
|
||||
this.ui.flash("Copied!");
|
||||
} else {
|
||||
this.showStatus("Copied last agent message to clipboard");
|
||||
|
|
@ -6121,7 +6227,7 @@ export class InteractiveMode {
|
|||
this.unsubscribe();
|
||||
}
|
||||
if (this.isInitialized) {
|
||||
this.ui.stop();
|
||||
this.stopInteractiveTui();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
this.unregisterSignalHandlers();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export class InteractiveThemeController {
|
|||
private terminalTheme: TerminalTheme = detectTerminalBackgroundFromEnv().theme;
|
||||
private activeThemeName: string | undefined;
|
||||
private autoSyncEnabled = false;
|
||||
private terminalColorSchemeUnsubscribe: (() => void) | undefined;
|
||||
|
||||
constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) {
|
||||
this.ui = ui;
|
||||
|
|
@ -31,7 +32,13 @@ export class InteractiveThemeController {
|
|||
this.onChanged = onChanged;
|
||||
this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme);
|
||||
initTheme(this.activeThemeName, true);
|
||||
this.ui.onTerminalColorSchemeChange((terminalTheme) => this.applyTerminalTheme(terminalTheme));
|
||||
this.bindTerminalColorSchemeListener();
|
||||
}
|
||||
|
||||
rebindTui(): void {
|
||||
this.terminalColorSchemeUnsubscribe?.();
|
||||
this.bindTerminalColorSchemeListener();
|
||||
this.ui.setTerminalColorSchemeNotifications(this.autoSyncEnabled);
|
||||
}
|
||||
|
||||
async applyFromSettings(): Promise<void> {
|
||||
|
|
@ -110,6 +117,12 @@ export class InteractiveThemeController {
|
|||
this.ui.setTerminalColorSchemeNotifications(enabled);
|
||||
}
|
||||
|
||||
private bindTerminalColorSchemeListener(): void {
|
||||
this.terminalColorSchemeUnsubscribe = this.ui.onTerminalColorSchemeChange((terminalTheme) =>
|
||||
this.applyTerminalTheme(terminalTheme),
|
||||
);
|
||||
}
|
||||
|
||||
private applyTerminalTheme(terminalTheme: TerminalTheme): void {
|
||||
if (!this.autoSyncEnabled) return;
|
||||
this.terminalTheme = terminalTheme;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import type { Component, Terminal } from "@earendil-works/pi-tui";
|
||||
import type { Component, Terminal, TUI } from "@earendil-works/pi-tui";
|
||||
import { Container, isViewportTUI, Text } from "@earendil-works/pi-tui";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts";
|
||||
import type { UiMode } from "../src/core/settings-manager.ts";
|
||||
import { createInteractiveTui, InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
|
||||
import {
|
||||
createInteractiveTui,
|
||||
createInteractiveTuiReference,
|
||||
InteractiveMode,
|
||||
} from "../src/modes/interactive/interactive-mode.ts";
|
||||
|
||||
const clipboardMocks = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn<(text: string) => Promise<void>>(),
|
||||
|
|
@ -14,11 +18,23 @@ vi.mock("../src/utils/clipboard.ts", () => clipboardMocks);
|
|||
|
||||
class RecordingTerminal extends VirtualTerminal implements Terminal {
|
||||
readonly writes: string[] = [];
|
||||
startCount = 0;
|
||||
stopCount = 0;
|
||||
|
||||
override start(onInput: (data: string) => void, onResize: () => void): void {
|
||||
this.startCount += 1;
|
||||
super.start(onInput, onResize);
|
||||
}
|
||||
|
||||
override write(data: string): void {
|
||||
this.writes.push(data);
|
||||
super.write(data);
|
||||
}
|
||||
|
||||
override stop(): void {
|
||||
this.stopCount += 1;
|
||||
super.stop();
|
||||
}
|
||||
}
|
||||
|
||||
describe("createInteractiveTui", () => {
|
||||
|
|
@ -51,6 +67,60 @@ describe("createInteractiveTui", () => {
|
|||
expect(altTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(true);
|
||||
altTui.stop();
|
||||
});
|
||||
|
||||
it("replaces the renderer while preserving components and focus", async () => {
|
||||
const terminal = new RecordingTerminal(40, 8);
|
||||
const renderer = createInteractiveTui({
|
||||
uiMode: "regular",
|
||||
showHardwareCursor: false,
|
||||
logDirectory: "/tmp",
|
||||
terminal,
|
||||
});
|
||||
let stableUi: TUI;
|
||||
const invalidatedModes: UiMode[] = [];
|
||||
const component: Component & { focused: boolean } = {
|
||||
focused: false,
|
||||
render: () => ["content"],
|
||||
invalidate: () => invalidatedModes.push(stableUi.mode),
|
||||
};
|
||||
renderer.addChild(component);
|
||||
renderer.setFocus(component);
|
||||
|
||||
type SwitchContext = {
|
||||
renderer: ReturnType<typeof createInteractiveTui>;
|
||||
ui: TUI;
|
||||
fullscreenLayoutRoot: Component;
|
||||
options: { uiMode?: UiMode };
|
||||
themeController: { rebindTui: () => void };
|
||||
extensionTerminalInputSubscriptions: Set<never>;
|
||||
};
|
||||
const context = Object.assign(Object.create(InteractiveMode.prototype), {
|
||||
renderer,
|
||||
ui: undefined as unknown as TUI,
|
||||
fullscreenLayoutRoot: component,
|
||||
options: { uiMode: "regular" as UiMode },
|
||||
themeController: { rebindTui: () => {} },
|
||||
extensionTerminalInputSubscriptions: new Set<never>(),
|
||||
}) as SwitchContext;
|
||||
stableUi = createInteractiveTuiReference(() => context.renderer);
|
||||
context.ui = stableUi;
|
||||
const { switchUiMode } = InteractiveMode.prototype as unknown as {
|
||||
switchUiMode(this: SwitchContext, mode: UiMode, restoreProgress?: boolean): boolean;
|
||||
};
|
||||
|
||||
renderer.start();
|
||||
await terminal.waitForRender();
|
||||
expect(switchUiMode.call(context, "fullscreen", false)).toBe(true);
|
||||
await terminal.waitForRender();
|
||||
|
||||
expect(stableUi.mode).toBe("fullscreen");
|
||||
expect(context.renderer.children).toEqual([component]);
|
||||
expect(context.renderer.getFocusedComponent()).toBe(component);
|
||||
expect(component.focused).toBe(true);
|
||||
expect(invalidatedModes).toEqual(["fullscreen"]);
|
||||
expect([terminal.startCount, terminal.stopCount]).toEqual([2, 1]);
|
||||
stableUi.stop();
|
||||
});
|
||||
});
|
||||
|
||||
type CopyCommandContext = {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
### Added
|
||||
|
||||
- Added the shared `TuiMode` type and `mode` discriminants to the main-screen and alternate-screen TUI renderers.
|
||||
- Added TUI lifecycle and render-state handoff APIs for replacing renderers without replaying main-screen content.
|
||||
- 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)).
|
||||
|
|
|
|||
|
|
@ -124,10 +124,11 @@ export {
|
|||
type TuiInputListener,
|
||||
type TuiInputListenerResult,
|
||||
type TuiMode,
|
||||
type TuiStopOptions,
|
||||
type ViewportTUI,
|
||||
} from "./tui.ts";
|
||||
export { TuiAltScreen, type TuiAltScreenOptions } from "./tui-alt-screen.ts";
|
||||
export { TuiMainScreen } from "./tui-main-screen.ts";
|
||||
export { TuiMainScreen, type TuiMainScreenRenderState } from "./tui-main-screen.ts";
|
||||
// Utilities
|
||||
export {
|
||||
getOsc8LinkAtColumn,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,15 @@ 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,
|
||||
TuiBase,
|
||||
type TuiStopOptions,
|
||||
VIEWPORT_TUI,
|
||||
type ViewportTUI,
|
||||
} from "./tui.ts";
|
||||
import {
|
||||
extractAnsiCode,
|
||||
getGraphemeCellRange,
|
||||
|
|
@ -164,11 +172,6 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
return this.layoutRoot?.render(width) ?? super.render(width);
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
this.layoutRoot?.invalidate();
|
||||
}
|
||||
|
||||
protected override getMountedRoots(): readonly Component[] {
|
||||
return this.layoutRoot ? [this.layoutRoot] : this.children;
|
||||
}
|
||||
|
|
@ -203,7 +206,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
);
|
||||
}
|
||||
|
||||
protected override beforeTerminalStop(): void {
|
||||
protected override beforeTerminalStop(_options: TuiStopOptions): void {
|
||||
this.stopSelectionAutoScroll();
|
||||
this.selectionPressActive = false;
|
||||
this.stopScrollbarHover();
|
||||
|
|
@ -216,21 +219,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
|||
this.uploadedKittyImages.clear();
|
||||
}
|
||||
|
||||
protected override afterTerminalStop(): void {
|
||||
protected override afterTerminalStop(options: TuiStopOptions): void {
|
||||
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 (options.preserveScreen) {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { deleteKittyImage, isImageLine } from "./terminal-image.ts";
|
||||
import { type TUI, TuiBase } from "./tui.ts";
|
||||
import { type TUI, TuiBase, type TuiStopOptions } from "./tui.ts";
|
||||
import { visibleWidth } from "./utils.ts";
|
||||
|
||||
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
||||
|
|
@ -43,6 +43,16 @@ function isTermuxSession(): boolean {
|
|||
return Boolean(process.env.TERMUX_VERSION);
|
||||
}
|
||||
|
||||
export interface TuiMainScreenRenderState {
|
||||
previousLines: string[];
|
||||
previousWidth: number;
|
||||
previousHeight: number;
|
||||
cursorRow: number;
|
||||
hardwareCursorRow: number;
|
||||
maxLinesRendered: number;
|
||||
previousViewportTop: number;
|
||||
}
|
||||
|
||||
/** TUI implementation that renders into the terminal's main screen and scrollback. */
|
||||
export class TuiMainScreen extends TuiBase implements TUI {
|
||||
readonly mode = "regular" as const;
|
||||
|
|
@ -55,6 +65,29 @@ export class TuiMainScreen extends TuiBase implements TUI {
|
|||
private maxLinesRendered = 0;
|
||||
private previousViewportTop = 0;
|
||||
|
||||
captureRenderState(): TuiMainScreenRenderState {
|
||||
return {
|
||||
previousLines: [...this.previousLines],
|
||||
previousWidth: this.previousWidth,
|
||||
previousHeight: this.previousHeight,
|
||||
cursorRow: this.cursorRow,
|
||||
hardwareCursorRow: this.hardwareCursorRow,
|
||||
maxLinesRendered: this.maxLinesRendered,
|
||||
previousViewportTop: this.previousViewportTop,
|
||||
};
|
||||
}
|
||||
|
||||
restoreRenderState(state: TuiMainScreenRenderState): void {
|
||||
this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line));
|
||||
this.previousKittyImageIds = new Set();
|
||||
this.previousWidth = state.previousWidth;
|
||||
this.previousHeight = state.previousHeight;
|
||||
this.cursorRow = state.cursorRow;
|
||||
this.hardwareCursorRow = state.hardwareCursorRow;
|
||||
this.maxLinesRendered = state.maxLinesRendered;
|
||||
this.previousViewportTop = state.previousViewportTop;
|
||||
}
|
||||
|
||||
protected override resetRenderState(): void {
|
||||
this.previousLines = [];
|
||||
this.previousWidth = -1;
|
||||
|
|
@ -65,8 +98,8 @@ export class TuiMainScreen extends TuiBase implements TUI {
|
|||
this.previousViewportTop = 0;
|
||||
}
|
||||
|
||||
protected override beforeTerminalStop(): void {
|
||||
if (this.previousLines.length === 0) return;
|
||||
protected override beforeTerminalStop(options: TuiStopOptions): void {
|
||||
if (options.preserveScreen || this.previousLines.length === 0) return;
|
||||
this.terminal.write(" ");
|
||||
const targetRow = this.previousLines.length;
|
||||
const lineDiff = targetRow - this.hardwareCursorRow;
|
||||
|
|
|
|||
|
|
@ -283,6 +283,11 @@ export function compositeTuiLine(
|
|||
|
||||
export type TuiMode = "regular" | "fullscreen";
|
||||
|
||||
export interface TuiStopOptions {
|
||||
/** Leave renderer output in place for another TUI taking over the same terminal. */
|
||||
preserveScreen?: boolean;
|
||||
}
|
||||
|
||||
export interface TUI extends Component {
|
||||
readonly mode: TuiMode;
|
||||
children: Component[];
|
||||
|
|
@ -301,7 +306,8 @@ export interface TUI extends Component {
|
|||
hideOverlay(): void;
|
||||
hasOverlay(): boolean;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
stop(options?: TuiStopOptions): void;
|
||||
renderNow(force?: boolean): void;
|
||||
requestRender(force?: boolean): void;
|
||||
addInputListener(listener: TuiInputListener): () => void;
|
||||
removeInputListener(listener: TuiInputListener): void;
|
||||
|
|
@ -348,7 +354,7 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
private focusOrderCounter = 0;
|
||||
private overlayStack: OverlayStackEntry[] = [];
|
||||
|
||||
protected get hasOverlayEntries(): boolean {
|
||||
get hasOverlayEntries(): boolean {
|
||||
return this.overlayStack.length > 0;
|
||||
}
|
||||
private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };
|
||||
|
|
@ -370,9 +376,9 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
|
||||
protected afterTerminalStart(): void {}
|
||||
|
||||
protected beforeTerminalStop(): void {}
|
||||
protected beforeTerminalStop(_options: TuiStopOptions): void {}
|
||||
|
||||
protected afterTerminalStop(): void {}
|
||||
protected afterTerminalStop(_options: TuiStopOptions): void {}
|
||||
|
||||
get fullRedraws(): number {
|
||||
return this.fullRedrawCount;
|
||||
|
|
@ -404,6 +410,10 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
this.clearOnShrink = enabled;
|
||||
}
|
||||
|
||||
getFocusedComponent(): Component | null {
|
||||
return this.focusedComponent;
|
||||
}
|
||||
|
||||
setFocus(component: Component | null): void {
|
||||
this.setFocusInternal({ component, overlayFocusRestore: "clear" });
|
||||
}
|
||||
|
|
@ -673,8 +683,8 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
}
|
||||
|
||||
override invalidate(): void {
|
||||
super.invalidate();
|
||||
for (const overlay of this.overlayStack) overlay.component.invalidate?.();
|
||||
for (const root of this.getMountedRoots()) root.invalidate();
|
||||
for (const overlay of this.overlayStack) overlay.component.invalidate();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
|
|
@ -731,7 +741,7 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
this.terminal.write("\x1b[16t");
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
stop(options: TuiStopOptions = {}): void {
|
||||
this.stopped = true;
|
||||
if (this.renderTimer) {
|
||||
clearTimeout(this.renderTimer);
|
||||
|
|
@ -740,10 +750,21 @@ export abstract class TuiBase extends Container implements TUI {
|
|||
if (this.terminalColorSchemeNotificationsEnabled) {
|
||||
this.terminal.write("\x1b[?2031l");
|
||||
}
|
||||
this.beforeTerminalStop();
|
||||
this.beforeTerminalStop(options);
|
||||
this.terminal.showCursor();
|
||||
this.terminal.stop();
|
||||
this.afterTerminalStop();
|
||||
this.afterTerminalStop(options);
|
||||
}
|
||||
|
||||
renderNow(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();
|
||||
}
|
||||
|
||||
requestRender(force = false): void {
|
||||
|
|
|
|||
|
|
@ -117,6 +117,22 @@ describe("TuiAltScreen", () => {
|
|||
tui.stop();
|
||||
});
|
||||
|
||||
it("invalidates overlays with an explicit layout root", () => {
|
||||
const tui = new TuiAltScreen(new VirtualTerminal());
|
||||
const overlay = new Text("overlay", 0, 0);
|
||||
let invalidated = false;
|
||||
overlay.invalidate = () => {
|
||||
invalidated = true;
|
||||
};
|
||||
tui.setLayoutRoot(new Text("root", 0, 0));
|
||||
tui.showOverlay(overlay);
|
||||
|
||||
tui.invalidate();
|
||||
|
||||
assert.strictEqual(invalidated, true);
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("routes wheel input to the scroll view under the pointer", async () => {
|
||||
const terminal = new VirtualTerminal(20, 4);
|
||||
const tui = new TuiAltScreen(terminal);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue