feat(coding-agent): add scrollbar thumb theme color

This commit is contained in:
Armin Ronacher 2026-07-31 17:41:15 +02:00
parent 7724472eab
commit b3ed27b3fe
8 changed files with 81 additions and 10 deletions

View file

@ -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

View file

@ -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 |

View file

@ -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 },

View file

@ -33,6 +33,7 @@
"thinkingText": "gray",
"selectedBg": "selectedBg",
"scrollbarThumb": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "text",
"customMessageBg": "customMsgBg",

View file

@ -32,6 +32,7 @@
"thinkingText": "mediumGray",
"selectedBg": "selectedBg",
"scrollbarThumb": "selectedBg",
"userMessageBg": "userMsgBg",
"userMessageText": "text",
"customMessageBg": "customMsgBg",

View file

@ -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"

View file

@ -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<T extends Record<string, ColorValue>>(
return resolved as Record<keyof T, string | number>;
}
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<ThemeColor, string | number>,
bgColors: Record<ThemeBg, string | number>,
bgColors: Record<Exclude<ThemeBg, "scrollbarThumb">, string | number> &
Partial<Record<"scrollbarThumb", string | number>>,
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<ThemeBg, string | number> = {} as Record<ThemeBg, string | number>;
const bgColorKeys: Set<string> = new Set([
"selectedBg",
"scrollbarThumb",
"userMessageBg",
"customMessageBg",
"toolPendingBg",

View file

@ -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<string, string | number> } {
return JSON.parse(readFileSync(new URL("../src/modes/interactive/theme/dark.json", import.meta.url), "utf8")) as {
name: string;
colors: Record<string, string | number>;
};
}
function writeTheme(theme: { name: string; colors: Record<string, string | number> }): 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");
});
});