diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7e5b53b..6ac5d27 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -342,7 +342,8 @@ The interactive TUI uses regular scrollback output by default. Set `ui.tuiMode` to `"fullscreen"` to use the terminal's alternate screen with an independently scrollable transcript, a fixed composer, and mouse-wheel/ scrollbar navigation. The composer remains available while older transcript -content is being reviewed. +content is being reviewed. The scrollbar is hidden when the transcript fits, +then appears briefly while scrolling and follows the active dark/light theme. ```json { diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index df427b7..4c1d058 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -1070,7 +1070,10 @@ class ZCodeTui { follow: "end", primary: true, overscroll: "chain", - scrollbar: "always" + // Keep the chrome quiet until the transcript actually overflows and + // the user scrolls. This is the default behavior used by pi-agent. + scrollbar: "auto", + scrollbarStyle: this.theme.scrollbarThumb }); this.fullscreenLayout ??= new VStack([ { component: this.fullscreenHeader, basis: 1, shrink: 0, minSize: 1 }, @@ -3908,7 +3911,7 @@ class ZCodeTui { { value: "fullscreen", label: "Fullscreen", - description: "Alternate screen with scrollable transcript and scrollbars" + description: "Alternate screen with a fixed composer and transient themed scrollbar" } ], selectedIndex: this.tuiMode === "fullscreen" ? 1 : 0 diff --git a/packages/zcode-tui/src/theme.ts b/packages/zcode-tui/src/theme.ts index 33709e9..b37e47b 100644 --- a/packages/zcode-tui/src/theme.ts +++ b/packages/zcode-tui/src/theme.ts @@ -28,6 +28,7 @@ interface ThemePalette { quote: string; toolPendingSurface: string; toolErrorSurface: string; + scrollbarThumb: string; diffAddedLine: string; diffRemovedLine: string; diffHunkLine: string; @@ -47,6 +48,7 @@ const palettes: Record = { quote: "38;5;109", toolPendingSurface: "38;5;252;48;5;236", toolErrorSurface: "38;5;252;48;5;52", + scrollbarThumb: "48;5;238", diffAddedLine: "38;5;120;48;5;22", diffRemovedLine: "38;5;210;48;5;52", diffHunkLine: "38;5;159;48;5;24", @@ -64,6 +66,7 @@ const palettes: Record = { quote: "38;5;24", toolPendingSurface: "38;5;58;48;5;230", toolErrorSurface: "38;5;236;48;5;224", + scrollbarThumb: "48;5;252", diffAddedLine: "38;5;22;48;5;194", diffRemovedLine: "38;5;88;48;5;224", diffHunkLine: "38;5;24;48;5;189", @@ -91,6 +94,7 @@ export interface ZCodeTheme { bold: (text: string) => string; toolPendingBackground: (text: string) => string; toolErrorBackground: (text: string) => string; + scrollbarThumb: (text: string) => string; diffAddedLine: (text: string) => string; diffRemovedLine: (text: string) => string; diffHunkLine: (text: string) => string; @@ -120,6 +124,7 @@ export function createTheme(enabled: boolean, initialColorScheme: ZCodeColorSche const quote = paletteStyle(state, "quote", enabled); const toolPendingBackground = paletteStyle(state, "toolPendingSurface", enabled); const toolErrorBackground = paletteStyle(state, "toolErrorSurface", enabled); + const scrollbarThumb = paletteStyle(state, "scrollbarThumb", enabled); const diffAddedLine = paletteStyle(state, "diffAddedLine", enabled); const diffRemovedLine = paletteStyle(state, "diffRemovedLine", enabled); const diffHunkLine = paletteStyle(state, "diffHunkLine", enabled); @@ -154,6 +159,7 @@ export function createTheme(enabled: boolean, initialColorScheme: ZCodeColorSche bold, toolPendingBackground, toolErrorBackground, + scrollbarThumb, diffAddedLine, diffRemovedLine, diffHunkLine, diff --git a/scripts/smoke-tui-fullscreen-layout.ts b/scripts/smoke-tui-fullscreen-layout.ts index 1739b65..872b697 100644 --- a/scripts/smoke-tui-fullscreen-layout.ts +++ b/scripts/smoke-tui-fullscreen-layout.ts @@ -75,8 +75,12 @@ async function runPhase(options: { copyOnSelect: boolean }): Promise { const timeout = setTimeout(() => child.kill("SIGKILL"), 20_000); let failure: unknown; try { + const startupOutputStart = output.length; await waitFor(/alpha\/model/i); const startupRows = screenRows(); + if (/\x1b\[(?:100|48;5;(?:238|252))m/u.test(output.slice(startupOutputStart))) { + throw new Error(`Fullscreen startup rendered a scrollbar thumb before scrolling.\n${startupRows.join("\n")}`); + } if (startupRows.some((row) => row.includes("SYSTEM INITIATED"))) { throw new Error(`Fullscreen header used the wide banner unexpectedly.\n${startupRows.join("\n")}`); } @@ -126,9 +130,13 @@ async function runPhase(options: { copyOnSelect: boolean }): Promise { terminal.write("long transcript\r"); await waitFor(/transcript line 80/i, turnStart); const beforeRows = screenRows(); + const scrollOutputStart = output.length; terminal.write("\x1b[5~"); await Bun.sleep(100); const rows = screenRows(); + if (!process.env.NO_COLOR && !/\x1b\[48;5;(?:238|252)m/u.test(output.slice(scrollOutputStart))) { + throw new Error(`Fullscreen transcript scroll did not render the themed scrollbar thumb.\n${rows.join("\n")}`); + } const firstTranscript = (lines: string[]): number => { const line = lines.find((value) => /^ transcript line \d+$/u.test(value)); return line ? Number(line.match(/\d+/u)?.[0] ?? 0) : 0; diff --git a/test/theme.test.ts b/test/theme.test.ts index 85b2758..a060bce 100644 --- a/test/theme.test.ts +++ b/test/theme.test.ts @@ -100,6 +100,16 @@ describe("TUI terminal theme", () => { expect(theme.select.selectedText("selected")).toContain("\x1b[38;5;25m"); }); + test("keeps fullscreen scrollbar thumbs aligned with the active theme", () => { + const theme = createTheme(true, "dark"); + expect(theme.scrollbarThumb(" ")).toStartWith("\x1b[48;5;238m"); + + theme.setColorScheme("light"); + expect(theme.scrollbarThumb(" ")).toStartWith("\x1b[48;5;252m"); + + expect(createTheme(false).scrollbarThumb(" ")).toBe(" "); + }); + test("never relies on the terminal default foreground for strong text", () => { const theme = createTheme(true, "dark"); expect(theme.bold("strong")).toStartWith("\x1b[1;38;5;252m");