From b3ed27b3fe33ad006df66709ccf7f2edb33871eb Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 31 Jul 2026 17:41:15 +0200 Subject: [PATCH] feat(coding-agent): add scrollbar thumb theme color --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/themes.md | 8 ++-- .../src/modes/interactive/interactive-mode.ts | 2 +- .../src/modes/interactive/theme/dark.json | 1 + .../src/modes/interactive/theme/light.json | 1 + .../modes/interactive/theme/theme-schema.json | 6 ++- .../src/modes/interactive/theme/theme.ts | 24 ++++++++-- .../coding-agent/test/scrollbar-theme.test.ts | 48 +++++++++++++++++++ 8 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/test/scrollbar-theme.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b5ebb8799..cb9aad4ac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,7 @@ - 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. +- Added an optional `scrollbarThumb` theme color for fullscreen scrollbar thumbs, falling back to `selectedBg`. ### Fixed diff --git a/packages/coding-agent/docs/themes.md b/packages/coding-agent/docs/themes.md index 8bb869517..2a00dee37 100644 --- a/packages/coding-agent/docs/themes.md +++ b/packages/coding-agent/docs/themes.md @@ -71,6 +71,7 @@ vim ~/.pi/agent/themes/my-theme.json "text": "", "thinkingText": "secondary", "selectedBg": "#2d2d30", + "scrollbarThumb": "#555566", "userMessageBg": "#2d2d30", "userMessageText": "", "customMessageBg": "#2d2d30", @@ -140,13 +141,13 @@ 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` is optional and falls back to `thinkingXhigh`. +- `colors` must define all 51 required tokens. `thinkingMax` is optional and falls back to `thinkingXhigh`; `scrollbarThumb` is optional and falls back to `selectedBg`. The `$schema` field enables editor auto-completion and validation. ## Color Tokens -Every theme must define all 51 required color tokens. `thinkingMax` is optional for compatibility with existing themes; when omitted, it uses `thinkingXhigh`. +Every theme must define all 51 required color tokens. `thinkingMax` and `scrollbarThumb` are optional for compatibility with existing themes; when omitted, they use `thinkingXhigh` and `selectedBg`, respectively. ### Core UI (11 colors) @@ -164,11 +165,12 @@ Every theme must define all 51 required color tokens. `thinkingMax` is optional | `text` | Default text (usually `""`) | | `thinkingText` | Thinking block text | -### Backgrounds & Content (11 colors) +### Backgrounds & Content (11 required, 1 optional) | Token | Purpose | |-------|---------| | `selectedBg` | Selected line background | +| `scrollbarThumb` | Fullscreen scrollbar thumb background; optional, falls back to `selectedBg` | | `userMessageBg` | User message background | | `userMessageText` | User message text | | `customMessageBg` | Extension message background | diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5a0bb64c5..dd740010c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -751,7 +751,7 @@ export class InteractiveMode { primary: true, overscroll: "chain", scrollbar: this.settingsManager.getFullscreenScrollbar(), - scrollbarStyle: (text) => theme.bg("selectedBg", text), + scrollbarStyle: (text) => theme.bg("scrollbarThumb", text), }); const dock = new TuiLayouts.VStack([ { component: this.pendingMessagesContainer, shrink: 1, minSize: 0 }, diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index d4d504155..9db9cbd8b 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -33,6 +33,7 @@ "thinkingText": "gray", "selectedBg": "selectedBg", + "scrollbarThumb": "selectedBg", "userMessageBg": "userMsgBg", "userMessageText": "text", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index ef0d5c303..74ef3d1c5 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -32,6 +32,7 @@ "thinkingText": "mediumGray", "selectedBg": "selectedBg", + "scrollbarThumb": "selectedBg", "userMessageBg": "userMsgBg", "userMessageText": "text", "customMessageBg": "customMsgBg", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index 4ab36a725..039bdc15f 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -34,7 +34,7 @@ }, "colors": { "type": "object", - "description": "Theme color definitions (thinkingMax is optional and falls back to thinkingXhigh)", + "description": "Theme color definitions (thinkingMax and scrollbarThumb are optional and use compatible fallbacks)", "required": [ "accent", "border", @@ -137,6 +137,10 @@ "$ref": "#/$defs/colorValue", "description": "Selected item background" }, + "scrollbarThumb": { + "$ref": "#/$defs/colorValue", + "description": "Fullscreen scrollbar thumb background (falls back to selectedBg when omitted)" + }, "userMessageBg": { "$ref": "#/$defs/colorValue", "description": "User message background" diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index cfd5df550..b38678629 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -45,8 +45,9 @@ const ThemeJsonSchema = Type.Object({ dim: ColorValueSchema, text: ColorValueSchema, thinkingText: ColorValueSchema, - // Backgrounds & Content Text (11 colors) + // Backgrounds & Content Text (11 required, 1 optional) selectedBg: ColorValueSchema, + scrollbarThumb: Type.Optional(ColorValueSchema), userMessageBg: ColorValueSchema, userMessageText: ColorValueSchema, customMessageBg: ColorValueSchema, @@ -156,6 +157,7 @@ export type ThemeColor = export type ThemeBg = | "selectedBg" + | "scrollbarThumb" | "userMessageBg" | "customMessageBg" | "toolPendingBg" @@ -319,8 +321,14 @@ function resolveThemeColors>( return resolved as Record; } -function withThemeColorFallbacks(colors: ThemeJson["colors"]): ThemeJson["colors"] & { thinkingMax: ColorValue } { - return { ...colors, thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh }; +function withThemeColorFallbacks( + colors: ThemeJson["colors"], +): ThemeJson["colors"] & { thinkingMax: ColorValue; scrollbarThumb: ColorValue } { + return { + ...colors, + thinkingMax: colors.thinkingMax ?? colors.thinkingXhigh, + scrollbarThumb: colors.scrollbarThumb ?? colors.selectedBg, + }; } // ============================================================================ @@ -337,7 +345,8 @@ export class Theme { constructor( fgColors: Record, - bgColors: Record, + bgColors: Record, string | number> & + Partial>, mode: ColorMode, options: { name?: string; sourcePath?: string; sourceInfo?: SourceInfo } = {}, ) { @@ -351,7 +360,11 @@ export class Theme { this.fgColors.set(key, fgAnsi(value, mode)); } this.bgColors = new Map(); - for (const [key, value] of Object.entries(bgColors) as [ThemeBg, string | number][]) { + const backgrounds = { + ...bgColors, + scrollbarThumb: bgColors.scrollbarThumb ?? bgColors.selectedBg, + }; + for (const [key, value] of Object.entries(backgrounds) as [ThemeBg, string | number][]) { this.bgColors.set(key, bgAnsi(value, mode)); } } @@ -601,6 +614,7 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode, sourcePath?: string const bgColors: Record = {} as Record; const bgColorKeys: Set = new Set([ "selectedBg", + "scrollbarThumb", "userMessageBg", "customMessageBg", "toolPendingBg", diff --git a/packages/coding-agent/test/scrollbar-theme.test.ts b/packages/coding-agent/test/scrollbar-theme.test.ts new file mode 100644 index 000000000..01dbdbcca --- /dev/null +++ b/packages/coding-agent/test/scrollbar-theme.test.ts @@ -0,0 +1,48 @@ +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"; + +const tempDirs: string[] = []; + +function loadDarkTheme(): { name: string; colors: Record } { + return JSON.parse(readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf8")) as { + name: string; + colors: Record; + }; +} + +function writeTheme(theme: { name: string; colors: Record }): string { + const testDir = mkdtempSync(join(tmpdir(), "pi-scrollbar-theme-")); + tempDirs.push(testDir); + const themePath = join(testDir, `${theme.name}.json`); + writeFileSync(themePath, JSON.stringify(theme)); + return themePath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("scrollbar theme color", () => { + it("falls back to selectedBg when scrollbarThumb is omitted", () => { + const themeJson = loadDarkTheme(); + themeJson.name = "legacy-scrollbar-theme"; + delete themeJson.colors.scrollbarThumb; + + const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); + expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe(loadedTheme.getBgAnsi("selectedBg")); + }); + + it("uses an explicitly configured scrollbarThumb", () => { + const themeJson = loadDarkTheme(); + themeJson.name = "custom-scrollbar-theme"; + themeJson.colors.scrollbarThumb = "#123456"; + + const loadedTheme = loadThemeFromPath(writeTheme(themeJson), "truecolor"); + expect(loadedTheme.getBgAnsi("scrollbarThumb")).toBe("\x1b[48;2;18;52;86m"); + }); +});