feat(tui): alt mode scrollbar but prettier (#8801)

This commit is contained in:
Cristina Poncela Cubeiro 2026-09-04 00:05:47 +02:00 • committed by GitHub
parent 79680533c6
commit 457ae8c79c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 292 additions and 140 deletions

View file

@ -40,7 +40,7 @@
- Added `fullscreenCopyOnSelect` to disable automatic fullscreen selection copy; when disabled, `Ctrl+X` copies the active text selection before falling back to the last assistant message, while `/tree` still copies the selected message ([#7720](https://github.com/earendil-works/pi/issues/7720)).
### Changed
- Changed fullscreen scrollbars to reveal on pointer entry, support optional `scrollbarTrack` and `scrollbarThumb` theme colors with muted and text fallbacks, keep one thumb color across normal and expanded states, and support track-click jumping.
- Changed fullscreen transcript search arrows to underline on hover and capitalized the search placeholder.
- Changed selectors in `/thinking`, `/model`, `/scoped-models`, `/trust`, per-model thinking settings, and theme settings to keep active options marked while browsing. `/scoped-models` now uses consistent per-item toggles and strikes through unavailable models ([#8900](https://github.com/earendil-works/pi/pull/8900)).

View file

@ -68,7 +68,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
| `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support |
| `tuiMode` | string | `"regular"` | Interactive TUI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--tui-mode` overrides this setting at startup |
| `fullscreenExitOutput` | string | `"transcript"` | Fullscreen exit output: `"transcript"` prints the final transcript and resume hint, while `"resume-hint"` restores the previous screen and prints only the resume hint. Has no effect in regular TUI mode |
| `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 TUI mode |
| `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling or while the pointer is over its rightmost-column track, `"always"` reserves that column and keeps it visible, and `"hidden"` hides it. Has no effect in regular TUI mode |
| `fullscreenCopyOnSelect` | boolean | `true` | Automatically copy selected text in fullscreen mode. When disabled, selections stay highlighted and `Ctrl+X` copies the active selection |
For VS Code, include `--wait` so pi resumes after the editor exits:

View file

@ -88,7 +88,8 @@ vim ~/.pi/agent/themes/my-theme.json
"text": "",
"thinkingText": "secondary",
"selectedBg": "#2d2d30",
"scrollbarThumb": "#555566",
"scrollbarTrack": "secondary",
"scrollbarThumb": "",
"searchMatchBg": "#2d2d30",
"searchMatchText": "",
"userMessageBg": "#2d2d30",
@ -160,15 +161,15 @@ vim ~/.pi/agent/themes/my-theme.json
- `name` is required, must be unique, and must not contain `/`.
- `vars` is optional. Define reusable colors here, then reference them in `colors`.
- `colors` must define all 51 required tokens. `thinkingMax`, `scrollbarThumb`, and the two search highlight tokens are optional and use the fallbacks listed below.
- `colors` must define all 53 required tokens. `thinkingMax` and the two search highlight tokens are optional and use the fallbacks listed below.
The `$schema` field enables editor auto-completion and validation.
## Color Tokens
Every theme must define all 51 required color tokens. The optional tokens preserve compatibility with existing themes: `thinkingMax` falls back to `thinkingXhigh`, `scrollbarThumb` and `searchMatchBg` fall back to `selectedBg`, and `searchMatchText` falls back to `text`. Other search matches use `searchMatchText` on `searchMatchBg` with an underline; the current match reverses that foreground/background pair and uses bold text.
Every theme must define all 53 required color tokens. The optional tokens preserve compatibility with existing themes: `thinkingMax` falls back to `thinkingXhigh`, `searchMatchBg` falls back to `selectedBg`, and `searchMatchText` falls back to `text`. Other search matches use `searchMatchText` on `searchMatchBg` with an underline; the current match reverses that foreground/background pair and uses bold text.
### Core UI (11 colors)
### Core UI (13 colors)
| Token | Purpose |
|-------|---------|
@ -183,13 +184,14 @@ Every theme must define all 51 required color tokens. The optional tokens preser
| `dim` | Tertiary text |
| `text` | Default text (usually `""`) |
| `thinkingText` | Thinking block text |
| `scrollbarTrack` | Fullscreen scrollbar track foreground |
| `scrollbarThumb` | Fullscreen scrollbar thumb foreground, shared by normal and expanded states |
### Backgrounds & Content (11 required, 3 optional)
### Backgrounds & Content (11 required, 2 optional)
| Token | Purpose |
|-------|---------|
| `selectedBg` | Selected line background |
| `scrollbarThumb` | Fullscreen scrollbar thumb background; optional, falls back to `selectedBg` |
| `searchMatchBg` | Transcript search match background and current-match text; optional, falls back to `selectedBg` |
| `searchMatchText` | Transcript search match text and current-match background; optional, falls back to `text` |
| `userMessageBg` | User message background |

View file

@ -30,6 +30,8 @@
"dim": "dimGray",
"text": "",
"thinkingText": "gray",
"scrollbarTrack": "darkGray",
"scrollbarThumb": "",
"selectedBg": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "",

View file

@ -146,7 +146,8 @@ export class ExperimentalClientTui implements Component {
status: this.#statusContainer,
editor: this.#editorContainer,
footer: this.#footerComponent,
scrollbarStyle: (text) => theme.bg("scrollbarThumb", text),
scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),
scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),
}).root;
this.#rebuild();
}

View file

@ -9,7 +9,8 @@ export interface ChatViewportOptions {
readonly widgetsAbove?: Component;
readonly widgetsBelow?: Component;
readonly scrollbar?: ScrollViewScrollbar;
readonly scrollbarStyle?: (text: string) => string;
readonly scrollbarTrackStyle?: (text: string) => string;
readonly scrollbarThumbStyle?: (text: string) => string;
}
export interface ChatViewport {
@ -24,7 +25,8 @@ export function createChatViewport(options: ChatViewportOptions): ChatViewport {
primary: true,
overscroll: "chain",
scrollbar: options.scrollbar ?? "auto",
...(options.scrollbarStyle === undefined ? {} : { scrollbarStyle: options.scrollbarStyle }),
...(options.scrollbarTrackStyle === undefined ? {} : { scrollbarTrackStyle: options.scrollbarTrackStyle }),
...(options.scrollbarThumbStyle === undefined ? {} : { scrollbarThumbStyle: options.scrollbarThumbStyle }),
});
const dock = new VStack([
{ component: options.pendingMessages, shrink: 1, minSize: 0 },

View file

@ -882,7 +882,8 @@ export class InteractiveMode {
widgetsBelow: this.widgetContainerBelow,
footer: this.footerContainer,
scrollbar: this.settingsManager.getFullscreenScrollbar(),
scrollbarStyle: (text) => theme.bg("scrollbarThumb", text),
scrollbarTrackStyle: (text) => theme.fg("scrollbarTrack", text),
scrollbarThumbStyle: (text) => theme.fg("scrollbarThumb", text),
});
this.transcriptScrollView = viewport.transcript;
this.fullscreenLayoutRoot = viewport.root;

View file

@ -33,7 +33,8 @@
"thinkingText": "gray",
"selectedBg": "selectedBg",
"scrollbarThumb": "selectedBg",
"scrollbarTrack": "darkGray",
"scrollbarThumb": "text",
"searchMatchBg": "selectedBg",
"searchMatchText": "text",
"userMessageBg": "userMsgBg",

View file

@ -32,7 +32,8 @@
"thinkingText": "mediumGray",
"selectedBg": "selectedBg",
"scrollbarThumb": "selectedBg",
"scrollbarTrack": "lightGray",
"scrollbarThumb": "text",
"searchMatchBg": "selectedBg",
"searchMatchText": "text",
"userMessageBg": "userMsgBg",

View file

@ -20,7 +20,7 @@ const ThemeJsonSchema = Type.Object({
name: Type.String(),
vars: Type.Optional(Type.Record(Type.String(), ColorValueSchema)),
colors: Type.Object({
// Core UI (10 colors)
// Core UI (11 colors)
accent: ColorValueSchema,
border: ColorValueSchema,
borderAccent: ColorValueSchema,
@ -32,9 +32,11 @@ const ThemeJsonSchema = Type.Object({
dim: ColorValueSchema,
text: ColorValueSchema,
thinkingText: ColorValueSchema,
// Backgrounds & Content Text (11 required, 3 optional)
selectedBg: ColorValueSchema,
// Scrollbar (2 optional colors)
scrollbarTrack: Type.Optional(ColorValueSchema),
scrollbarThumb: Type.Optional(ColorValueSchema),
// Backgrounds & Content Text (11 required, 2 optional)
selectedBg: ColorValueSchema,
searchMatchBg: Type.Optional(ColorValueSchema),
searchMatchText: Type.Optional(ColorValueSchema),
userMessageBg: ColorValueSchema,

View file

@ -34,7 +34,7 @@
},
"colors": {
"type": "object",
"description": "Theme color definitions (thinkingMax, scrollbarThumb, and search highlight colors are optional and use compatible fallbacks)",
"description": "Theme color definitions (scrollbar, thinkingMax, and search highlight colors are optional and use compatible fallbacks)",
"required": [
"accent",
"border",
@ -137,9 +137,13 @@
"$ref": "#/$defs/colorValue",
"description": "Selected item background"
},
"scrollbarTrack": {
"$ref": "#/$defs/colorValue",
"description": "Fullscreen scrollbar track foreground (falls back to muted when omitted)"
},
"scrollbarThumb": {
"$ref": "#/$defs/colorValue",
"description": "Fullscreen scrollbar thumb background (falls back to selectedBg when omitted)"
"description": "Fullscreen scrollbar thumb foreground (falls back to text when omitted)"
},
"searchMatchBg": {
"$ref": "#/$defs/colorValue",

View file

@ -50,6 +50,8 @@ export type ThemeColor =
| "dim"
| "text"
| "thinkingText"
| "scrollbarTrack"
| "scrollbarThumb"
| "searchMatchText"
| "userMessageText"
| "customMessageText"
@ -89,7 +91,6 @@ export type ThemeColor =
export type ThemeBg =
| "selectedBg"
| "scrollbarThumb"
| "searchMatchBg"
| "userMessageBg"
| "customMessageBg"
@ -97,8 +98,8 @@ export type ThemeBg =
| "toolSuccessBg"
| "toolErrorBg";
type OptionalThemeColor = "thinkingMax" | "searchMatchText";
type OptionalThemeBg = "scrollbarThumb" | "searchMatchBg";
type OptionalThemeColor = "scrollbarTrack" | "scrollbarThumb" | "thinkingMax" | "searchMatchText";
type OptionalThemeBg = "searchMatchBg";
type ColorMode = "truecolor" | "256color";
@ -258,15 +259,17 @@ function resolveThemeColors<T extends Record<string, ColorValue>>(
}
function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & {
thinkingMax: ColorValue;
scrollbarTrack: ColorValue;
scrollbarThumb: ColorValue;
thinkingMax: ColorValue;
searchMatchBg: ColorValue;
searchMatchText: ColorValue;
} {
return {
...colors,
scrollbarTrack: colors.scrollbarTrack ?? colors.muted,
scrollbarThumb: colors.scrollbarThumb ?? colors.text,
thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh,
scrollbarThumb: colors.scrollbarThumb ?? colors.selectedBg,
searchMatchBg: colors.searchMatchBg ?? colors.selectedBg,
searchMatchText: colors.searchMatchText ?? colors.text,
};
@ -299,6 +302,8 @@ export class Theme {
this.fgColors = new Map();
const colors = {
...fgColors,
scrollbarTrack: fgColors.scrollbarTrack ?? fgColors.muted,
scrollbarThumb: fgColors.scrollbarThumb ?? fgColors.text,
thinkingMax: fgColors.thinkingMax ?? fgColors.thinkingXhigh,
searchMatchText: fgColors.searchMatchText ?? fgColors.text,
};
@ -308,7 +313,6 @@ export class Theme {
this.bgColors = new Map();
const backgrounds = {
...bgColors,
scrollbarThumb: bgColors.scrollbarThumb ?? bgColors.selectedBg,
searchMatchBg: bgColors.searchMatchBg ?? bgColors.selectedBg,
};
for (const [key, value] of Object.entries(backgrounds) as [ThemeBg, string | number][]) {
@ -528,7 +532,6 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string
const bgColors: Record<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
const bgColorKeys: Set<string> = new Set([
"selectedBg",
"scrollbarThumb",
"searchMatchBg",
"userMessageBg",
"customMessageBg",

View file

@ -2,7 +2,10 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadThemeFromPath } from "../src/modes/interactive/theme/theme.ts";
import { loadThemeFromPath, setThemeJsonValidator } from "../src/modes/interactive/theme/theme.ts";
import { validateThemeJson } from "../src/modes/interactive/theme/theme-json.ts";
setThemeJsonValidator(validateThemeJson);
const tempDirs: string[] = [];
@ -27,23 +30,28 @@ afterEach(() => {
}
});
describe("optional fullscreen theme colors", () => {
it("falls back to selectedBg when scrollbarThumb is omitted", () => {
describe("fullscreen theme colors", () => {
it.each([
["scrollbarTrack", "muted"],
["scrollbarThumb", "text"],
] as const)("falls back to %s when omitted", (token, fallback) => {
const themeJson = loadDarkTheme();
themeJson.name = "legacy-scrollbar-theme";
delete themeJson.colors.scrollbarThumb;
themeJson.name = `missing-${token}-theme`;
delete themeJson.colors[token];
const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor");
expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe(loadedTheme.getBgAnsi("selectedBg"));
expect(loadedTheme.getFgAnsi(token)).toBe(loadedTheme.getFgAnsi(fallback));
});
it("uses an explicitly configured scrollbarThumb", () => {
it("uses explicitly configured scrollbar colors", () => {
const themeJson = loadDarkTheme();
themeJson.name = "custom-scrollbar-theme";
themeJson.colors.scrollbarTrack = "#654321";
themeJson.colors.scrollbarThumb = "#123456";
const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor");
expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe("\x1b[48;2;18;52;86m");
expect(loadedTheme.getFgAnsi("scrollbarTrack")).toBe("\x1b[38;2;101;67;33m");
expect(loadedTheme.getFgAnsi("scrollbarThumb")).toBe("\x1b[38;2;18;52;86m");
});
it("falls back to existing selection and text colors for search highlights", () => {

View file

@ -19,6 +19,7 @@
### Changed
- Changed fullscreen scrollbars to render muted thin tracks with contrasting proportional two-cell-minimum thumbs, preserve underlying backgrounds without inheriting foreground styles, reserve an unstyled column in `always` mode, reveal hidden `auto` tracks on pointer entry, expand the same-colored thumb on hover, and support track-click jumping in addition to thumb dragging.
- Changed fullscreen transcript search to use a bordered, placeholder-based input with a muted result count, right-aligned clickable key-and-arrow buttons with configurable hover styling, and open-shortcut toggling.
### Fixed

View file

@ -706,7 +706,7 @@ if (matchesKey(data, Key.enter)) {
2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render
3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Scrollbars support hover expansion, thumb dragging, and track-click jumping. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.

View file

@ -9,7 +9,8 @@ export interface ScrollViewOptions {
primary?: boolean;
overscroll?: "chain" | "contain";
scrollbar?: ScrollViewScrollbar;
scrollbarStyle?: (text: string) => string;
scrollbarTrackStyle?: (text: string) => string;
scrollbarThumbStyle?: (text: string) => string;
scrollbarHideDelayMs?: number;
}
@ -23,7 +24,8 @@ export class ScrollView extends Container {
readonly followEnd: boolean;
readonly primary: boolean;
readonly overscroll: "chain" | "contain";
readonly scrollbarStyle: (text: string) => string;
readonly scrollbarTrackStyle: (text: string) => string;
readonly scrollbarThumbStyle: (text: string) => string;
private currentScrollbar: ScrollViewScrollbar;
private readonly scrollbarHideDelayMs: number;
private currentScrollTop = 0;
@ -48,7 +50,8 @@ export class ScrollView extends Container {
this.primary = options.primary ?? false;
this.overscroll = options.overscroll ?? "chain";
this.currentScrollbar = options.scrollbar ?? "hidden";
this.scrollbarStyle = options.scrollbarStyle ?? ((text) => `\x1b[100m${text}\x1b[49m`);
this.scrollbarTrackStyle = options.scrollbarTrackStyle ?? ((text) => `\x1b[90m${text}\x1b[39m`);
this.scrollbarThumbStyle = options.scrollbarThumbStyle ?? ((text) => `\x1b[37m${text}\x1b[39m`);
this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000));
}
@ -75,6 +78,10 @@ export class ScrollView extends Container {
);
}
get isScrollbarActive(): boolean {
return this.scrollbarActive;
}
setScrollbar(scrollbar: ScrollViewScrollbar): void {
if (scrollbar === this.currentScrollbar) return;
this.currentScrollbar = scrollbar;
@ -114,6 +121,7 @@ export class ScrollView extends Container {
if (active === this.scrollbarActive) return;
this.scrollbarActive = active;
this.markScrollbarActivity();
this.requestRenderCallback?.();
}
scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void {

View file

@ -3,7 +3,13 @@ import { allocateStackSizes, visibleStackEntries } from "./components/stack.ts";
import { getLayoutNode } from "./layout-node.ts";
import { cropKittyImageLine, getKittyImageMetadata, isImageLine } from "./terminal-image.ts";
import { type Component, CURSOR_MARKER, compositeTuiLine } from "./tui.ts";
import { extractAnsiCode, getGraphemeCellRange, sliceByColumn, visibleWidth } from "./utils.ts";
import {
extractAnsiCode,
getActiveBackgroundAnsi,
getGraphemeCellRange,
sliceByColumn,
visibleWidth,
} from "./utils.ts";
const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
@ -240,7 +246,13 @@ function layoutComponent(
return box;
}
function styleScrollbarCell(line: string, column: number, totalWidth: number, style: (text: string) => string): string {
function replaceScrollbarCell(
line: string,
column: number,
totalWidth: number,
replacement: string,
preserveTargetBackground: boolean,
): string {
if (isImageLine(line)) return line;
const graphemeRange = getGraphemeCellRange(line, column);
@ -258,16 +270,20 @@ function styleScrollbarCell(line: string, column: number, totalWidth: number, st
targetPrefix += ansi.code;
targetIndex += ansi.length;
}
const targetText = target.slice(targetIndex) || " ".repeat(end - start);
const beforePadding = " ".repeat(Math.max(0, start - visibleWidth(before)));
return `${before}${beforePadding}${targetPrefix}${style(targetText)}${after}`;
const cellPaddingBefore = " ".repeat(Math.max(0, column - start));
const cellPaddingAfter = " ".repeat(Math.max(0, end - column - 1));
const targetStyle = `\x1b[0m\x1b]8;;\x07${preserveTargetBackground ? getActiveBackgroundAnsi(targetPrefix) : ""}`;
return `${before}${beforePadding}${targetStyle}${cellPaddingBefore}${replacement}${cellPaddingAfter}${after}`;
}
export function getScrollbarGeometry(box: LayoutBox): ScrollbarGeometry | undefined {
if (!box.scrollView?.isScrollbarVisible || box.rect.width <= 0 || box.rect.height <= 0) return undefined;
export function getScrollbarGeometry(box: LayoutBox, includeHiddenAuto = false): ScrollbarGeometry | undefined {
if (!box.scrollView || box.rect.width <= 0 || box.rect.height <= 0) return undefined;
const contentHeight = box.children[0]?.rect.height ?? box.scrollContentLines?.length ?? 0;
const trackHeight = box.rect.height;
const canRevealHiddenAuto = includeHiddenAuto && box.scrollView.scrollbar === "auto" && contentHeight > trackHeight;
if (!box.scrollView.isScrollbarVisible && !canRevealHiddenAuto) return undefined;
const minThumbHeight = Math.min(2, trackHeight);
const thumbHeight = Math.max(
@ -294,10 +310,20 @@ function paintScrollbar(box: LayoutBox, screen: string[], totalWidth: number): v
const geometry = getScrollbarGeometry(box);
if (!geometry || !box.scrollView) return;
for (let offset = 0; offset < geometry.thumbHeight; offset++) {
const row = geometry.thumbTop + offset;
for (let offset = 0; offset < geometry.trackHeight; offset++) {
const row = geometry.trackTop + offset;
if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue;
screen[row] = styleScrollbarCell(screen[row] ?? "", geometry.column, totalWidth, box.scrollView.scrollbarStyle);
const isThumb = row >= geometry.thumbTop && row < geometry.thumbTop + geometry.thumbHeight;
const replacement = isThumb
? box.scrollView.scrollbarThumbStyle(box.scrollView.isScrollbarActive ? "█" : "┃")
: box.scrollView.scrollbarTrackStyle("│");
screen[row] = replaceScrollbarCell(
screen[row] ?? "",
geometry.column,
totalWidth,
replacement,
box.scrollView.scrollbar !== "always",
);
}
}

View file

@ -1002,16 +1002,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return true;
}
private getScrollbarTargetAt(x: number, y: number): ScrollbarTarget | undefined {
private getScrollbarTargetAt(x: number, y: number, includeHiddenAuto = false): ScrollbarTarget | undefined {
if (this.hasOverlay() || !this.currentLayout) return undefined;
for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) {
const box = getScrollViewBox(this.currentLayout, scrollView);
const geometry = box ? getScrollbarGeometry(box) : undefined;
const geometry = box ? getScrollbarGeometry(box, includeHiddenAuto) : undefined;
if (
geometry &&
x === geometry.column &&
y >= geometry.thumbTop &&
y < geometry.thumbTop + geometry.thumbHeight
y >= geometry.trackTop &&
y < geometry.trackTop + geometry.trackHeight
) {
return { scrollView, geometry };
}
@ -1027,13 +1027,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
}
private updateScrollbarHover(x: number, y: number): void {
this.setScrollbarHover(this.getScrollbarTargetAt(x, y)?.scrollView);
this.setScrollbarHover(this.getScrollbarTargetAt(x, y, true)?.scrollView);
}
private stopScrollbarHover(): void {
this.setScrollbarHover(undefined);
}
private scrollScrollbarToPointer(
scrollView: ScrollView,
geometry: ScrollbarGeometry,
pointerY: number,
grabOffset: number,
): void {
const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
const thumbOffset = Math.max(0, Math.min(maxThumbOffset, pointerY - geometry.trackTop - grabOffset));
const scrollTop = maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
scrollView.scrollTo(scrollTop);
}
private handleScrollbarMouseEvent(event: SgrMouseEvent): boolean {
if (this.scrollbarDrag) {
if (event.release) {
@ -1045,14 +1057,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
: undefined;
const geometry = box ? getScrollbarGeometry(box) : undefined;
if (geometry) {
const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight;
const thumbOffset = Math.max(
0,
Math.min(maxThumbOffset, event.y - geometry.trackTop - this.scrollbarDrag.grabOffset),
this.scrollScrollbarToPointer(
this.scrollbarDrag.scrollView,
geometry,
event.y,
this.scrollbarDrag.grabOffset,
);
const scrollTop =
maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop);
this.scrollbarDrag.scrollView.scrollTo(scrollTop);
}
return true;
}
@ -1070,9 +1080,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.pressedUrl = undefined;
this.selectionDragged = false;
this.setScrollbarHover(target.scrollView);
const onThumb =
event.y >= target.geometry.thumbTop && event.y < target.geometry.thumbTop + target.geometry.thumbHeight;
const grabOffset = onThumb ? event.y - target.geometry.thumbTop : Math.floor(target.geometry.thumbHeight / 2);
if (!onThumb) this.scrollScrollbarToPointer(target.scrollView, target.geometry, event.y, grabOffset);
this.scrollbarDrag = {
scrollView: target.scrollView,
grabOffset: event.y - target.geometry.thumbTop,
grabOffset,
};
return true;
}
@ -1572,14 +1586,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.scrollToEndIndicatorRect = undefined;
const scrollView = layout.primaryScrollView ?? this.implicitScrollView;
if (!this.scrollToEndIndicator || !scrollView.followEnd || scrollView.isFollowingEnd) return screen;
const clip = getScrollViewBox(layout, scrollView)?.clip;
const box = getScrollViewBox(layout, scrollView);
const clip = box?.clip;
if (!clip || clip.width <= 0 || clip.height <= 0) return screen;
const row = clip.y + clip.height - 1;
if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen;
const text = truncateToWidth(this.scrollToEndIndicator(), clip.width, "");
const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
const textWidth = visibleWidth(text);
if (textWidth === 0) return screen;
const column = clip.x + Math.floor((clip.width - textWidth) / 2);
const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
const result = [...screen];
result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
this.scrollToEndIndicatorRect = { row, column, width: textWidth };

View file

@ -692,6 +692,10 @@ class AnsiCodeTracker {
return result;
}
getActiveBackgroundCode(): string {
return this.bgColor ? `\x1b[${this.bgColor}m` : "";
}
hasActiveCodes(): boolean {
return (
this.bold ||
@ -739,6 +743,13 @@ function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
}
}
/** Return only the background color active at the end of an ANSI-styled string. */
export function getActiveBackgroundAnsi(text: string): string {
const tracker = new AnsiCodeTracker();
updateTrackerFromText(text, tracker);
return tracker.getActiveBackgroundCode();
}
/**
* Split text into words while keeping ANSI codes attached.
*/

View file

@ -190,79 +190,112 @@ describe("viewport layout", () => {
assert.strictEqual(scrollView.isFollowingEnd, true);
});
it("renders a transient proportional scrollbar without replacing cell content", async () => {
it("renders a proportional glyph scrollbar with an expanded active thumb", async () => {
const sourceLines = ["abcd界", "abcde2", "abcde3", "abcde4", "abcde5", "abcde6", "abcde7", "abcde8"];
const contentBackground = "\x1b[42m";
const scrollbarBackground = "\x1b[48;5;1m";
const scrollbarStyle = (text: string) => `${scrollbarBackground}${text}\x1b[49m`;
const trackColor = "\x1b[38;5;2m";
const thumbColor = "\x1b[38;5;1m";
const scrollbarTrackStyle = (text: string) => `${trackColor}${text}\x1b[39m`;
const scrollbarThumbStyle = (text: string) => `${thumbColor}${text}\x1b[39m`;
const content = new Text(sourceLines.join("\n"), 0, 0, (text) => `${contentBackground}${text}\x1b[49m`);
const scrollView = new ScrollView(content, {
scrollbar: "auto",
scrollbarStyle,
scrollbarTrackStyle,
scrollbarThumbStyle,
scrollbarHideDelayMs: 10,
});
const render = () => renderLayoutFrame(scrollView, 6, 4, () => {}).lines;
const thumbRows = (lines: string[]) => lines.map((line) => line.includes(scrollbarBackground));
const visible = (lines: string[]) => lines.map(stripTerminalSequences);
let lines = render();
assert.deepStrictEqual(thumbRows(lines), [false, false, false, false]);
assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(0, 4));
assert.deepStrictEqual(visible(lines), sourceLines.slice(0, 4));
scrollView.scrollBy(2);
lines = render();
assert.deepStrictEqual(thumbRows(lines), [false, true, true, false]);
assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(2, 6));
assert.ok(lines[1]!.lastIndexOf(contentBackground) < lines[1]!.lastIndexOf(scrollbarBackground));
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde┃", "abcde┃", "abcde│"]);
assert.deepStrictEqual(
lines.map((line) => line.includes(trackColor)),
[true, false, false, true],
);
assert.deepStrictEqual(
lines.map((line) => line.includes(thumbColor)),
[false, true, true, false],
);
scrollView.setScrollbarActive(true);
lines = render();
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde█", "abcde█", "abcde│"]);
assert.deepStrictEqual(
lines.map((line) => line.includes(thumbColor)),
[false, true, true, false],
);
assert.ok(lines[1]!.lastIndexOf(contentBackground) < lines[1]!.lastIndexOf(thumbColor));
scrollView.setScrollbarActive(false);
await new Promise((resolve) => setTimeout(resolve, 30));
lines = render();
assert.deepStrictEqual(thumbRows(lines), [false, false, false, false]);
assert.deepStrictEqual(visible(lines), sourceLines.slice(2, 6));
scrollView.scrollToEnd();
lines = render();
assert.deepStrictEqual(thumbRows(lines), [false, false, true, true]);
assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(4));
assert.deepStrictEqual(visible(lines), ["abcde│", "abcde│", "abcde┃", "abcde┃"]);
scrollView.scrollToStart();
lines = render();
assert.strictEqual(visible(lines)[0], "abcd ┃");
const followedContent = new Text(sourceLines.join("\n"), 0, 0);
const followed = new ScrollView(followedContent, {
follow: "end",
scrollbar: "auto",
scrollbarStyle,
scrollbarTrackStyle,
scrollbarThumbStyle,
});
renderLayoutFrame(followed, 6, 4, () => {});
assert.strictEqual(followed.scrollTop, 4);
followedContent.setText(`${sourceLines.join("\n")}\nabcde9`);
const growthFrame = renderLayoutFrame(followed, 6, 4, () => {});
assert.strictEqual(followed.scrollTop, 5);
assert.ok(growthFrame.lines.every((line) => !line.includes(scrollbarBackground)));
assert.ok(growthFrame.lines.every((line) => !/[│┃]/.test(stripTerminalSequences(line))));
const fittingContent = new Text("1\n2", 0, 0);
const automatic = new ScrollView(fittingContent, { scrollbar: "auto", scrollbarStyle });
const automatic = new ScrollView(fittingContent, { scrollbar: "auto", scrollbarThumbStyle });
renderLayoutFrame(automatic, 6, 4, () => {});
automatic.scrollBy(1);
assert.ok(
renderLayoutFrame(automatic, 6, 4, () => {}).lines.every((line) => !line.includes(scrollbarBackground)),
renderLayoutFrame(automatic, 6, 4, () => {}).lines.every((line) => !/[│┃]/.test(stripTerminalSequences(line))),
);
const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarStyle });
const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarThumbStyle });
const alwaysFittingFrame = renderLayoutFrame(alwaysFitting, 6, 4, () => {});
assert.strictEqual(alwaysFittingFrame.root.children[0]?.rect.width, 5);
assert.ok(alwaysFittingFrame.lines.every((line) => line.includes(scrollbarBackground)));
assert.ok(visible(alwaysFittingFrame.lines).every((line) => line.endsWith("┃")));
const alwaysOverflowing = new ScrollView(content, { scrollbar: "always", scrollbarStyle });
const alwaysOverflowing = new ScrollView(content, {
scrollbar: "always",
scrollbarTrackStyle,
scrollbarThumbStyle,
});
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);
assert.strictEqual(visible(alwaysOverflowingFrame.lines).filter((line) => line.endsWith("┃")).length, 2);
assert.strictEqual(visible(alwaysOverflowingFrame.lines).filter((line) => line.endsWith("│")).length, 2);
for (const line of alwaysOverflowingFrame.lines) {
const scrollbarStyleIndex = Math.max(line.lastIndexOf(trackColor), line.lastIndexOf(thumbColor));
const reservedColumnResetIndex = line.lastIndexOf("\x1b[0m\x1b]8;;\x07", scrollbarStyleIndex);
assert.ok(reservedColumnResetIndex > line.lastIndexOf(contentBackground));
}
const thumbHeightFor = (contentHeight: number) => {
const sized = new ScrollView(new Text(Array.from({ length: contentHeight }, () => "x").join("\n"), 0, 0), {
scrollbar: "auto",
scrollbarStyle,
scrollbarThumbStyle,
});
renderLayoutFrame(sized, 6, 20, () => {});
sized.scrollBy(1);
return renderLayoutFrame(sized, 6, 20, () => {}).lines.filter((line) => line.includes(scrollbarBackground))
.length;
return renderLayoutFrame(sized, 6, 20, () => {}).lines.filter((line) =>
stripTerminalSequences(line).endsWith("┃"),
).length;
};
assert.strictEqual(thumbHeightFor(21), 19);
assert.strictEqual(thumbHeightFor(40), 10);
@ -270,11 +303,39 @@ describe("viewport layout", () => {
assert.strictEqual(thumbHeightFor(400), 2);
});
it("preserves only the underlying background beneath overlay scrollbar glyphs", () => {
const background = "\x1b[42m";
const borderForeground = "\x1b[31m";
const content = {
render: (width: number) =>
Array.from(
{ length: 8 },
() => `${background}${"x".repeat(width - 1)}${borderForeground}│\x1b[39m\x1b[49m`,
),
invalidate: () => {},
};
const scrollView = new ScrollView(content, {
scrollbar: "auto",
scrollbarTrackStyle: (text) => text,
scrollbarThumbStyle: (text) => text,
});
renderLayoutFrame(scrollView, 6, 4, () => {});
scrollView.scrollBy(1);
const frame = renderLayoutFrame(scrollView, 6, 4, () => {});
assert.deepStrictEqual(frame.lines.map(stripTerminalSequences), ["xxxxx│", "xxxxx┃", "xxxxx┃", "xxxxx│"]);
for (const line of frame.lines) {
assert.ok(line.includes(background));
assert.ok(!line.includes(borderForeground));
assert.ok(line.includes(`\x1b[0m\x1b]8;;\x07${background}`));
}
});
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.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);

View file

@ -134,6 +134,36 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("leaves the scrollbar clickable when the jump-to-end indicator spans the transcript", async () => {
const terminal = new VirtualTerminal(30, 6);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
scrollToEndIndicator: () => "↓".repeat(30),
});
const transcript = new ScrollView(
new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ follow: "end", primary: true, scrollbar: "always" },
);
tui.setLayoutRoot(
new VStack([
{ component: transcript, basis: 0, grow: 1, minSize: 1 },
{ component: new Text("editor\nfooter", 0, 0), basis: "auto", minSize: 1 },
]),
);
tui.start();
await terminal.waitForRender();
terminal.sendInput("\x1b[<64;1;1M");
await terminal.waitForRender();
assert.strictEqual(transcript.isFollowingEnd, false);
// The indicator must not intercept a press on the scrollbar's last column.
terminal.sendInput("\x1b[<0;30;4M");
terminal.sendInput("\x1b[<0;30;4m");
await terminal.waitForRender();
assert.strictEqual(transcript.isFollowingEnd, false);
tui.stop();
});
it("never shows the jump-to-end indicator for a primary scroll view without follow-end", async () => {
const terminal = new VirtualTerminal(30, 3);
const tui = new TuiAltScreen(terminal, undefined, undefined, {
@ -358,83 +388,54 @@ describe("TuiAltScreen", () => {
}
});
it("drags a visible scrollbar thumb and keeps it visible until release", async () => {
it("reveals an auto scrollbar when the pointer enters its hidden track", async () => {
const terminal = new RecordingTerminal(10, 5);
const tui = new TuiAltScreen(terminal);
const scrollView = new ScrollView(
new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{
primary: true,
scrollbar: "auto",
scrollbarHideDelayMs: 50,
},
{ primary: true, scrollbar: "auto", scrollbarHideDelayMs: 20 },
);
tui.setLayoutRoot(scrollView);
tui.start();
await terminal.waitForRender();
assert.strictEqual(scrollView.isScrollbarVisible, false);
terminal.sendInput("\x1b[<65;10;1M");
terminal.sendInput("\x1b[<35;10;3M");
await terminal.waitForRender();
assert.strictEqual(scrollView.scrollTop, 1);
assert.strictEqual(scrollView.isScrollbarVisible, true);
assert.strictEqual(scrollView.isScrollbarActive, true);
assert.ok(terminal.getViewport().some((line) => /[│█]/.test(line)));
terminal.sendInput("\x1b[<0;10;1M");
terminal.sendInput("\x1b[<35;9;3M");
await new Promise((resolve) => setTimeout(resolve, 40));
await terminal.waitForRender();
await new Promise((resolve) => setTimeout(resolve, 70));
assert.strictEqual(scrollView.isScrollbarVisible, true);
terminal.sendInput("\x1b[<32;10;4M");
await terminal.waitForRender();
assert.strictEqual(scrollView.scrollTop, 15);
assert.deepStrictEqual(
terminal.getViewport().map((line) => line.trimEnd()),
["line 16", "line 17", "line 18", "line 19", "line 20"],
);
terminal.sendInput("\x1b[<0;10;4m");
await terminal.waitForRender();
assert.strictEqual(scrollView.isScrollbarVisible, true);
await new Promise((resolve) => setTimeout(resolve, 70));
assert.strictEqual(scrollView.isScrollbarVisible, true);
terminal.sendInput("\x1b[<35;9;4M");
await new Promise((resolve) => setTimeout(resolve, 70));
assert.strictEqual(scrollView.isScrollbarVisible, false);
terminal.sendInput("\x1b[<64;10;5M");
await terminal.waitForRender();
assert.strictEqual(scrollView.scrollTop, 14);
await new Promise((resolve) => setTimeout(resolve, 70));
assert.strictEqual(scrollView.isScrollbarVisible, true);
terminal.sendInput("\x1b[<35;9;5M");
await new Promise((resolve) => setTimeout(resolve, 70));
assert.strictEqual(scrollView.isScrollbarVisible, false);
assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")));
tui.stop();
});
it("keeps the scrollbar column selectable while the thumb is hidden", async () => {
const terminal = new RecordingTerminal(10, 2);
it("jumps to a scrollbar track position and continues dragging from there", async () => {
const terminal = new RecordingTerminal(10, 10);
const tui = new TuiAltScreen(terminal);
const scrollView = new ScrollView(new Text("123456789A\nabcdefghij\nmore\nlines", 0, 0), {
scrollbar: "auto",
});
const scrollView = new ScrollView(
new Text(Array.from({ length: 50 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0),
{ primary: true, scrollbar: "always" },
);
tui.setLayoutRoot(scrollView);
tui.start();
await terminal.waitForRender();
assert.strictEqual(scrollView.isScrollbarVisible, false);
assert.strictEqual(scrollView.scrollTop, 0);
terminal.sendInput("\x1b[<0;10;1M");
terminal.sendInput("\x1b[<32;10;2M");
terminal.sendInput("\x1b[<0;10;2m");
terminal.sendInput("\x1b[<0;10;6M");
await terminal.waitForRender();
assert.strictEqual(scrollView.scrollTop, 20);
const expected = `\x1b]52;c;${Buffer.from("A\nabcdefghij").toString("base64")}\x07`;
assert.ok(
terminal.events.some((event) => event.type === "write" && event.data.includes(expected)),
JSON.stringify(terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;"))),
);
terminal.sendInput("\x1b[<32;10;10M");
await terminal.waitForRender();
assert.strictEqual(scrollView.scrollTop, 40);
terminal.sendInput("\x1b[<0;10;10m");
await terminal.waitForRender();
assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")));
tui.stop();
});