feat(tui): add configurable fullscreen scrollbars

This commit is contained in:
Armin Ronacher 2026-07-31 17:16:25 +02:00
parent 12a4b24292
commit 6129a353b1
13 changed files with 153 additions and 39 deletions

View file

@ -7,7 +7,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 a sticky editor, status, widget, and footer dock to fullscreen mode while keeping the transcript independently scrollable.
- Added a transient draggable transcript scrollbar to fullscreen mode.
- 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.
### Fixed

View file

@ -66,6 +66,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
| `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 |
| `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:

View file

@ -1,5 +1,6 @@
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Transport } from "@earendil-works/pi-ai";
import type { ScrollViewScrollbar } from "@earendil-works/pi-tui";
import { randomUUID } from "crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
@ -129,6 +130,7 @@ export interface Settings {
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it
uiMode?: UiMode; // default: "regular"
fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular UI mode
}
/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@ -1133,6 +1135,17 @@ export class SettingsManager {
this.save();
}
getFullscreenScrollbar(): ScrollViewScrollbar {
const mode = this.settings.fullscreenScrollbar;
return mode === "always" || mode === "hidden" ? mode : "auto";
}
setFullscreenScrollbar(mode: ScrollViewScrollbar): void {
this.globalSettings.fullscreenScrollbar = mode;
this.markModified("fullscreenScrollbar");
this.save();
}
getImageAutoResize(): boolean {
return this.settings.images?.autoResize ?? true;
}

View file

@ -4,6 +4,7 @@ import {
type Component,
Container,
getCapabilities,
type ScrollViewScrollbar,
type SelectItem,
SelectList,
type SelectListLayoutOptions,
@ -80,6 +81,7 @@ export interface SettingsConfig {
clearOnShrink: boolean;
showTerminalProgress: boolean;
uiMode: UiMode;
fullscreenScrollbar: ScrollViewScrollbar;
warnings: WarningSettings;
}
@ -112,6 +114,7 @@ export interface SettingsCallbacks {
onClearOnShrinkChange: (enabled: boolean) => void;
onShowTerminalProgressChange: (enabled: boolean) => void;
onUiModeChange: (mode: UiMode) => void;
onFullscreenScrollbarChange: (mode: ScrollViewScrollbar) => void;
onWarningsChange: (warnings: WarningSettings) => void;
onCancel: () => void;
}
@ -619,6 +622,13 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.uiMode,
values: ["regular", "fullscreen"],
},
{
id: "fullscreen-scrollbar",
label: "Fullscreen scrollbar",
description: "Scrollbar behavior in fullscreen mode; has no effect in regular mode",
currentValue: config.fullscreenScrollbar,
values: ["auto", "always", "hidden"],
},
{
id: "theme",
label: "Theme",
@ -831,6 +841,9 @@ export class SettingsSelectorComponent extends Container {
case "ui-mode":
callbacks.onUiModeChange(newValue as UiMode);
break;
case "fullscreen-scrollbar":
callbacks.onFullscreenScrollbarChange(newValue as ScrollViewScrollbar);
break;
case "theme":
callbacks.onThemeChange(newValue);
break;

View file

@ -351,6 +351,7 @@ export class InteractiveMode {
private loadedResourcesContainer: Container;
private chatContainer: Container;
private documentContainer: Container;
private transcriptScrollView: TuiLayouts.ScrollView | undefined;
private pendingMessagesContainer: Container;
private statusContainer: Container;
private defaultEditor: CustomEditor;
@ -745,11 +746,11 @@ export class InteractiveMode {
// Populate stable regions before selecting the renderer-specific composition.
this.renderWidgets(); // Initialize with default spacer
if (TuiLayouts.isViewportTUI(this.ui)) {
const transcript = new TuiLayouts.ScrollView(this.documentContainer, {
this.transcriptScrollView = new TuiLayouts.ScrollView(this.documentContainer, {
follow: "end",
primary: true,
overscroll: "chain",
scrollbar: "auto",
scrollbar: this.settingsManager.getFullscreenScrollbar(),
scrollbarStyle: (text) => theme.bg("selectedBg", text),
});
const dock = new TuiLayouts.VStack([
@ -762,7 +763,7 @@ export class InteractiveMode {
]);
this.ui.setLayoutRoot(
new TuiLayouts.VStack([
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: this.transcriptScrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 },
{ component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 },
]),
);
@ -1769,8 +1770,13 @@ export class InteractiveMode {
this.showStartupNoticesIfNeeded();
}
private applyFullscreenScrollbarSetting(): void {
this.transcriptScrollView?.setScrollbar(this.settingsManager.getFullscreenScrollbar());
}
private applyRuntimeSettings(): void {
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.applyFullscreenScrollbarSetting();
this.footer.setSession(this.session);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
@ -4237,6 +4243,7 @@ export class InteractiveMode {
clearOnShrink: this.settingsManager.getClearOnShrink(),
showTerminalProgress: this.settingsManager.getShowTerminalProgress(),
uiMode: this.settingsManager.getUiMode(),
fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
warnings: this.settingsManager.getWarnings(),
},
{
@ -4381,6 +4388,10 @@ export class InteractiveMode {
this.settingsManager.setUiMode(mode);
this.showStatus(`UI mode: ${mode} (restart required)`);
},
onFullscreenScrollbarChange: (mode) => {
this.settingsManager.setFullscreenScrollbar(mode);
this.applyFullscreenScrollbarSetting();
},
onWarningsChange: (warnings) => {
this.settingsManager.setWarnings(warnings);
},
@ -5452,7 +5463,6 @@ export class InteractiveMode {
try {
await this.session.reload({ beforeSessionStart: restoreChatBeforeSessionStart });
restoreChatBeforeSessionStart();
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.keybindings.reload();
const activeHeader = this.customHeader ?? this.builtInHeader;
if (isExpandable(activeHeader)) {
@ -5460,20 +5470,7 @@ export class InteractiveMode {
}
setRegisteredThemes(this.session.resourceLoader.getThemes().themes);
await this.themeController.applyFromSettings();
const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor.setPaddingX(editorPaddingX);
this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);
if (this.editor !== this.defaultEditor) {
this.editor.setPaddingX?.(editorPaddingX);
this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
}
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
const clearOnShrink = this.settingsManager.getClearOnShrink();
this.ui.setClearOnShrink(clearOnShrink);
if (!clearOnShrink && !this.activeStatusIndicator) {
this.statusContainer.clear();
}
this.applyRuntimeSettings();
this.setupAutocompleteProvider();
const runner = this.session.extensionRunner;
this.setupExtensionShortcuts(runner);

View file

@ -420,6 +420,18 @@ describe("SettingsManager", () => {
});
});
it("validates and persists the fullscreen scrollbar mode", async () => {
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getFullscreenScrollbar()).toBe("auto");
manager.setFullscreenScrollbar("hidden");
await manager.flush();
expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).fullscreenScrollbar).toBe("hidden");
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ fullscreenScrollbar: "sometimes" }));
expect(SettingsManager.create(projectDir, agentDir).getFullscreenScrollbar()).toBe("auto");
});
describe("outputPad", () => {
it("should default to 1 and persist binary values", async () => {
const manager = SettingsManager.create(projectDir, agentDir);

View file

@ -0,0 +1,37 @@
import { setKeybindings } from "@earendil-works/pi-tui";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { KeybindingsManager } from "../src/core/keybindings.ts";
import {
type SettingsCallbacks,
type SettingsConfig,
SettingsSelectorComponent,
} from "../src/modes/interactive/components/settings-selector.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
describe("SettingsSelectorComponent", () => {
beforeAll(() => {
initTheme("dark");
setKeybindings(new KeybindingsManager());
});
it("cycles through fullscreen scrollbar modes", () => {
const onChange = vi.fn();
const selector = new SettingsSelectorComponent(
{
fullscreenScrollbar: "auto",
warnings: {},
availableThinkingLevels: [],
availableThemes: [],
} as unknown as SettingsConfig,
{ onFullscreenScrollbarChange: onChange } as unknown as SettingsCallbacks,
);
const settingsList = selector.getSettingsList();
for (const character of "Fullscreen scrollbar") settingsList.handleInput(character);
settingsList.handleInput("\r");
settingsList.handleInput("\r");
settingsList.handleInput("\r");
expect(onChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]);
});
});

View file

@ -15,7 +15,7 @@
- Added interface-compatible main-screen and alternate-screen TUI renderers with application-owned scrolling ([#7304](https://github.com/earendil-works/pi/issues/7304)).
- 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 transient proportional scrollbars with mouse dragging and Home/End document navigation to alternate-screen `ScrollView`s.
- 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.
- Added page scrolling and OSC 133 semantic prompt navigation to the alternate-screen viewport.
## [0.83.0] - 2026-07-29

View file

@ -1,12 +1,14 @@
import { LAYOUT_NODE, type ScrollLayoutNode } from "../layout-node.ts";
import { type Component, Container } from "../tui.ts";
export type ScrollViewScrollbar = "hidden" | "auto" | "always";
export interface ScrollViewOptions {
axis?: "vertical";
follow?: "none" | "end";
primary?: boolean;
overscroll?: "chain" | "contain";
scrollbar?: "hidden" | "auto" | "always";
scrollbar?: ScrollViewScrollbar;
scrollbarStyle?: (text: string) => string;
scrollbarHideDelayMs?: number;
}
@ -16,8 +18,8 @@ export class ScrollView extends Container {
private readonly followEnd: boolean;
readonly primary: boolean;
readonly overscroll: "chain" | "contain";
readonly scrollbar: "hidden" | "auto" | "always";
readonly scrollbarStyle: (text: string) => string;
private currentScrollbar: ScrollViewScrollbar;
private readonly scrollbarHideDelayMs: number;
private currentScrollTop = 0;
private contentHeight = 0;
@ -39,7 +41,7 @@ export class ScrollView extends Container {
this.followingEnd = this.followEnd;
this.primary = options.primary ?? false;
this.overscroll = options.overscroll ?? "chain";
this.scrollbar = options.scrollbar ?? "hidden";
this.currentScrollbar = options.scrollbar ?? "hidden";
this.scrollbarStyle = options.scrollbarStyle ?? ((text) => `\x1b[100m${text}\x1b[49m`);
this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000));
}
@ -56,13 +58,29 @@ export class ScrollView extends Container {
return this.currentViewportHeight;
}
get scrollbar(): ScrollViewScrollbar {
return this.currentScrollbar;
}
get isScrollbarVisible(): boolean {
if (this.scrollbar === "always") return this.currentViewportHeight > 0;
return (
this.contentHeight > this.currentViewportHeight &&
(this.scrollbar === "always" || this.transientScrollbarVisible)
this.scrollbar === "auto" && this.contentHeight > this.currentViewportHeight && this.transientScrollbarVisible
);
}
setScrollbar(scrollbar: ScrollViewScrollbar): void {
if (scrollbar === this.currentScrollbar) return;
this.currentScrollbar = scrollbar;
if (scrollbar !== "auto") this.hideTransientScrollbar();
else if (this.scrollbarActive) this.markScrollbarActivity();
this.requestRenderCallback?.();
}
getContentWidth(width: number): number {
return this.scrollbar === "always" && width > 1 ? width - 1 : width;
}
private markScrollbarActivity(): void {
if (this.scrollbar !== "auto" || this.contentHeight <= this.currentViewportHeight) return;
this.transientScrollbarVisible = true;
@ -166,7 +184,9 @@ export class ScrollView extends Container {
}
override render(width: number): string[] {
return this.child.render(width);
const contentWidth = this.getContentWidth(width);
const lines = this.child.render(contentWidth);
return contentWidth === width ? lines : lines.map((line) => `${line} `);
}
[LAYOUT_NODE](): ScrollLayoutNode {

View file

@ -18,7 +18,7 @@ export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts
export { Input } from "./components/input.ts";
export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts";
export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts";
export { ScrollView, type ScrollViewOptions } from "./components/scroll-view.ts";
export { ScrollView, type ScrollViewOptions, type ScrollViewScrollbar } from "./components/scroll-view.ts";
export {
type SelectItem,
SelectList,

View file

@ -29,6 +29,7 @@ export interface ScrollLayoutState {
readonly primary: boolean;
readonly overscroll: "chain" | "contain";
readonly viewportHeight: number;
getContentWidth(width: number): number;
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void;
}

View file

@ -129,7 +129,16 @@ function layoutComponent(
if (node.type === "scroll") {
const previousScrollTop = node.state.scrollTop;
const childBox = layoutComponent(context, node.component, x, y - previousScrollTop, safeWidth, undefined, clip);
const contentWidth = node.state.getContentWidth(safeWidth);
const childBox = layoutComponent(
context,
node.component,
x,
y - previousScrollTop,
contentWidth,
undefined,
clip,
);
const contentHeight = childBox.rect.height;
const viewportHeight = height === undefined ? contentHeight : Math.max(0, Math.floor(height));
node.state.updateLayout(contentHeight, viewportHeight, context.requestRender);
@ -144,7 +153,7 @@ function layoutComponent(
clip: childClip,
children: [childBox],
scrollView,
scrollContentLines: renderCached(context, node.component, safeWidth),
scrollContentLines: renderCached(context, node.component, contentWidth),
layer: 0,
};
childBox.parent = box;
@ -261,9 +270,9 @@ export function getScrollbarGeometry(box: LayoutBox): ScrollbarGeometry | undefi
minThumbHeight,
Math.min(trackHeight, Math.round((trackHeight * trackHeight) / contentHeight)),
);
const maxScrollTop = contentHeight - trackHeight;
const maxScrollTop = Math.max(0, contentHeight - trackHeight);
const maxThumbTop = trackHeight - thumbHeight;
const thumbOffset = Math.round((box.scrollView.scrollTop / maxScrollTop) * maxThumbTop);
const thumbOffset = maxScrollTop === 0 ? 0 : Math.round((box.scrollView.scrollTop / maxScrollTop) * maxThumbTop);
const column = box.rect.x + box.rect.width - 1;
if (column < box.clip.x || column >= box.clip.x + box.clip.width) return undefined;

View file

@ -159,16 +159,14 @@ describe("viewport layout", () => {
);
const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarStyle });
assert.ok(
renderLayoutFrame(alwaysFitting, 6, 4, () => {}).lines.every((line) => !line.includes(scrollbarBackground)),
);
const alwaysFittingFrame = renderLayoutFrame(alwaysFitting, 6, 4, () => {});
assert.strictEqual(alwaysFittingFrame.root.children[0]?.rect.width, 5);
assert.ok(alwaysFittingFrame.lines.every((line) => line.includes(scrollbarBackground)));
const alwaysOverflowing = new ScrollView(content, { scrollbar: "always", scrollbarStyle });
assert.strictEqual(
renderLayoutFrame(alwaysOverflowing, 6, 4, () => {}).lines.filter((line) => line.includes(scrollbarBackground))
.length,
2,
);
const alwaysOverflowingFrame = renderLayoutFrame(alwaysOverflowing, 6, 4, () => {});
assert.strictEqual(alwaysOverflowingFrame.root.children[0]?.rect.width, 5);
assert.strictEqual(alwaysOverflowingFrame.lines.filter((line) => line.includes(scrollbarBackground)).length, 2);
const thumbHeightFor = (contentHeight: number) => {
const sized = new ScrollView(new Text(Array.from({ length: contentHeight }, () => "x").join("\n"), 0, 0), {
@ -186,6 +184,19 @@ describe("viewport layout", () => {
assert.strictEqual(thumbHeightFor(400), 2);
});
it("updates reserved scrollbar layout at runtime", () => {
const scrollView = new ScrollView(new Text("123456", 0, 0), { scrollbar: "always" });
const render = () => renderLayoutFrame(new HStack([scrollView], { align: "start" }), 6, 2, () => {});
const always = render();
assert.deepStrictEqual(visibleLines(always.lines), ["12345", "6"]);
assert.strictEqual(always.root.children[0]?.rect.width, 6);
assert.strictEqual(always.root.children[0]?.children[0]?.rect.width, 5);
scrollView.setScrollbar("hidden");
assert.strictEqual(render().root.children[0]?.children[0]?.rect.width, 6);
assert.strictEqual(scrollView.isScrollbarVisible, false);
});
it("measures nested scroll content from constrained child geometry", () => {
const inner = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0));
const outer = new ScrollView(new VStack([{ component: inner, basis: 2 }, new Text("tail", 0, 0)]));