diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c3caa5bd5..b5ebb8799 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index eea08f1b6..b0b4e985c 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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: diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index dd26c7175..75748253b 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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; } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 90ea81d75..c1ee4f501 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 94c7fed95..5a0bb64c5 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 58c952054..64eea52dc 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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); diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts new file mode 100644 index 000000000..d8f3c1e22 --- /dev/null +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -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"]); + }); +}); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 8d8232561..6ddae34c2 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/components/scroll-view.ts b/packages/tui/src/components/scroll-view.ts index 93a68e427..f2c0b5fd7 100644 --- a/packages/tui/src/components/scroll-view.ts +++ b/packages/tui/src/components/scroll-view.ts @@ -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 { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index cd8ecc292..ab5f875dc 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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, diff --git a/packages/tui/src/layout-node.ts b/packages/tui/src/layout-node.ts index f66a4d18d..a3c06b245 100644 --- a/packages/tui/src/layout-node.ts +++ b/packages/tui/src/layout-node.ts @@ -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; } diff --git a/packages/tui/src/layout.ts b/packages/tui/src/layout.ts index cc2ec16ec..66306a83e 100644 --- a/packages/tui/src/layout.ts +++ b/packages/tui/src/layout.ts @@ -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; diff --git a/packages/tui/test/layout.test.ts b/packages/tui/test/layout.test.ts index 716059074..a280cd3ef 100644 --- a/packages/tui/test/layout.test.ts +++ b/packages/tui/test/layout.test.ts @@ -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)]));