pi-claude-code-ui 1.0.90 → 1.0.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  > [!IMPORTANT]
4
4
  > **1.0.69 — package rename (permanent).** Canonical npm name is now [`pi-claude-code-ui`](https://www.npmjs.com/package/pi-claude-code-ui). `pi-claude-style-tools` is legacy and will not receive further releases. Install with `pi install npm:pi-claude-code-ui` or `npm i pi-claude-code-ui`.
5
5
 
6
+ ## 1.0.92 — 2026-10-24
7
+
8
+ ### Changed
9
+
10
+ - **Terminal colors are on by default** - on pi 1.4 or newer, pi now follows the terminal's palette (Ghostty, Rex, ...) without `/cc-theme terminal on`. The first start switches to pi's `system` theme, saves the theme you had as `terminalThemePrevious`, and says so; `/cc-theme terminal off` (or `"terminalTheme": false`) restores that theme and keeps it. `pi --use-theme <name>` still wins for that run, and older pi versions are left alone.
11
+ - **Branch connectors have one rule and no settings** - `├ └ │` are always a faint mark at a fixed 2:1 contrast against the terminal background, which keeps them visible on dark, light, and tinted themes. The `/cc-tools branch` command and the `toolBranchColorMode` / `toolBranchRgbGray` settings are removed; existing values are ignored. On pi before 1.4 the contrast is taken against black or white (rgb 72 on dark themes, as before).
12
+
13
+ ## 1.0.91 — 2026-10-24
14
+
15
+ ### Added
16
+
17
+ - **Follow the terminal's colors** - `/cc-theme terminal on` (setting `terminalTheme`) switches pi to its built-in `system` theme, which takes every color from the palette Ghostty, Rex, or any other terminal reports, and keeps it on at every start. `/cc-theme terminal off` restores the previous theme. Terminals do not announce switches between two themes of the same kind (dark to dark), so the extension also asks the terminal for its color scheme when its window regains focus, and pi re-reads the palette.
18
+
19
+ ### Fixed
20
+
21
+ - **Diffs use the theme's colors** - diff code was highlighted with GitHub's palette (`github-dark` / `github-light`) whatever the pi theme, so it never matched Markdown code blocks or the terminal. Diffs now use the theme's `syntax*` colors, and diff rows tint the terminal background toward the theme's own `toolDiffAdded` / `toolDiffRemoved` instead of mixing in fixed green and pink. Needs pi 1.4 or newer; older versions keep the GitHub palettes.
22
+ - **Connectors and outlines follow the theme's tint** - branch connectors were a fixed neutral rgb(72) and box outlines rgb(136) on every theme, which looked heavy on light themes and gray on tinted ones. The default now blends the terminal background toward its text color to the same contrast (2:1 and 5.1:1), so dark terminals look as before. An explicit `/cc-tools branch <0-255>` still sets an exact gray.
23
+ - **Light and dark agree with pi** - light/dark choices (diff palette, chrome) now come from pi's `theme.appearance` instead of a separate brightness guess, so they can no longer disagree with pi's own UI.
24
+ - **Light themes no longer gray out syntax colors** - the contrast fix for bundled Shiki themes replaced every color above a fixed brightness with gray on light themes. It now only applies to the bundled themes.
25
+ - **`diffColors.shikiTheme` is kept on theme switches** - a light/dark switch overwrote it with `github-dark` / `github-light`.
26
+ - **Transparent tool backgrounds on pi 1.4** - pi 1.4 renamed the theme's background table, so `toolBackground` overrides were silently skipped.
27
+
6
28
  ## 1.0.90 — 2026-10-19
7
29
 
8
30
  ### Fixed
package/README.md CHANGED
@@ -28,9 +28,10 @@ Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots,
28
28
  - **Subagent completion notifications** restyled to match the same Claude-style tool rows
29
29
  - **RTK rewrite integration** that folds rewrite notices into the bash tool row with a muted `(RTK)` badge and expanded-only rewrite details
30
30
  - **Transparent tool backgrounds** in `transparent` or `border` mode
31
- - **Theme-adaptive palette** — borders, branch connectors, dim text, spinner accent, and diff backgrounds automatically follow the active pi theme (set `themeAdaptive: false` to keep the fixed Claude-style palette)
32
- - **Light Ghostty-sync themes** — edit/write diffs use `github-light` highlighting and light-tinted diff rows; tool pending dots use softer chrome colors
33
- - **Transparent edit/write diffs** with universal red/green diff colors
31
+ - **Terminal colors** (default, pi 1.4+) — pi's theme follows the terminal's own palette (Ghostty, Rex, kitty, WezTerm, …), including switches between two dark or two light themes; `/cc-theme terminal off` keeps a theme of your choice
32
+ - **Theme-adaptive palette** — dim text, spinner accent, diff highlighting, and diff backgrounds automatically follow the active pi theme (set `themeAdaptive: false` to keep the fixed Claude-style palette)
33
+ - **Faint branch connectors** — `├ └ │` keep the same faint contrast on every terminal background, dark or light
34
+ - **Transparent edit/write diffs** tinted with the theme's own diff colors
34
35
  - **Grouped consecutive tool calls** with single-row summaries for repeated targets and per-tool glance rows for mixed work (set `groupToolCalls: false` to disable)
35
36
  - **Extra detail toggle** with `Ctrl+Shift+O`, increasing expanded preview caps without making the default view heavy
36
37
  - **Global border patch** for all tool rows, including unknown/custom tools
@@ -59,35 +60,53 @@ Set in `.pi/settings.json` or the active Pi agent settings file (`$PI_CODING_AGE
59
60
  "diffCollapsedLines": 24,
60
61
  "diffLayout": "auto",
61
62
  "themeAdaptive": true,
62
- "diffTheme": "github-dark"
63
+ "terminalTheme": true
63
64
  }
64
65
  ```
65
66
 
67
+ Leave `diffTheme` unset to highlight diffs with the theme's colors; set it (for example `"github-dark"`) to force a fixed diff palette.
68
+
66
69
  ### Theme integration
67
70
 
68
71
  When `themeAdaptive` is `true` (default), the following colors are derived from the active pi theme on every render and re-derived whenever the theme changes:
69
72
 
70
73
  | Element | Derived from |
71
74
  |---------|--------------|
72
- | User box, tool rules, code fences | `dim` → `muted` → `borderMuted` → `thinkingText` |
73
- | Branch connectors (`├`, `└`, `│`) | **fixed rgb(72)** by default (theme-independent); `/cc-tools branch theme` to follow pi theme |
74
75
  | "✻ Turn took Ns" line (final message only, with session total + turn count) | `muted` |
75
76
  | Expanded thinking-block text and `∴` marker | `muted` |
76
77
  | Diff add/remove accents | `toolDiffAdded` / `toolDiffRemoved` |
77
- | Diff background tints | mixed against `toolSuccessBg` base |
78
+ | Diff code highlighting | the theme's `syntax*` colors, the same ones pi uses for Markdown code blocks |
79
+ | Diff background tints | the terminal background tinted toward `toolDiffAdded` / `toolDiffRemoved` |
80
+ | Light or dark variants | pi's own decision (`theme.appearance`) |
78
81
  | Spinner verb text (`Working…`) | `borderAccent` (fallback: `accent`) |
79
82
  | Spinner status text | `muted` |
80
83
 
84
+ These need pi 1.4 or newer, which exposes the theme's concrete colors. On older pi versions diffs use `github-dark` / `github-light`, and diff tints are mixed against `toolSuccessBg`.
85
+
86
+ Branch connectors (`├`, `└`, `│`) and outlines (user box, tool rules, code fences, thinking rule) are neutral marks with nothing to configure. They blend the terminal's background toward its text color until they reach a fixed contrast: 2:1 for connectors (rgb 72 on a near-black terminal) and 5.1:1 for outlines (rgb ~136), so connectors stay faint but visible on dark, light, and tinted themes alike. On pi versions before 1.4, which don't report the terminal's colors, the same contrasts are taken against black or white.
87
+
81
88
  User-supplied `diffTheme` presets and `diffColors` overrides always win over theme-derived defaults. File-type icons (e.g. `ts`, `py`, `rs`) keep their language-identity colors and are not theme-derived.
82
89
 
83
90
  Set `themeAdaptive: false` to keep the original fixed Claude-style palette regardless of the active pi theme.
84
91
 
85
- On `/resume`, `/new`, or `/fork`, tool chrome is rebound from the **current** pi theme (no coupling to Ghostty or other theme extensions). If you use Ghostty sync, listing it **above** this extension in `settings.json` is recommended so `setTheme` runs before chrome rebind.
92
+ On `/resume`, `/new`, or `/fork`, tool chrome is rebound from the **current** pi theme, so it also follows themes set by other extensions.
93
+
94
+ ### Terminal colors (Ghostty, Rex, …)
95
+
96
+ On by default: at startup the extension switches pi to its built-in `system` theme, which builds every pi color from the palette the terminal reports, and keeps it there on every start. The theme you had before is saved as `terminalThemePrevious`; `/cc-theme terminal off` (or `"terminalTheme": false`) restores it and keeps it. `pi --use-theme <name>` still picks the theme for that run, and on pi versions without the `system` theme (before 1.4) nothing changes.
97
+
98
+ Pi already rebuilds the `system` theme when the terminal switches between light and dark. Terminals send no notification when you switch between two themes of the same kind, for example from one dark Rex theme to another, so this extension also asks the terminal for its color scheme whenever the terminal window regains focus (you went to the terminal's settings and came back). Pi then re-reads the palette and repaints only if the colors changed. The query is answered by Ghostty, Rex, and other terminals that support color scheme reports; in regular (non-fullscreen) mode the extension turns on focus reporting itself and keeps the focus events out of the editor.
99
+
100
+ ```text
101
+ /cc-theme terminal on # follow the terminal's colors
102
+ /cc-theme terminal off # go back to the previous theme and keep it
103
+ /cc-theme terminal status # show the current state
104
+ ```
86
105
 
87
106
  #### Toggle at runtime with `/cc-theme`
88
107
 
89
108
  ```text
90
- /cc-theme # show current setting + theme name
109
+ /cc-theme # show current setting + theme name (and whether terminal colors are on)
91
110
  /cc-theme status # show current setting + color preview (incl. spinner)
92
111
  /cc-theme on # follow pi theme
93
112
  /cc-theme off # keep fixed Claude palette
@@ -17,6 +17,6 @@
17
17
  "diffCollapsedLines": 24,
18
18
  "showTruncationHints": false,
19
19
  "themeAdaptive": true,
20
- "diffTheme": "github-dark",
20
+ "terminalTheme": true,
21
21
  "diffColors": {}
22
22
  }
@@ -71,18 +71,35 @@ import {
71
71
  getLastBashOutputLine,
72
72
  } from "./bash-command";
73
73
  import { getUserSettingsPath } from "./settings-path";
74
+ import {
75
+ blendForContrast,
76
+ mixRgb as mixThemeRgb,
77
+ rgbBgAnsi,
78
+ rgbFgAnsi,
79
+ rgbToHex,
80
+ shikiThemeFromPiTheme,
81
+ terminalDefaultColors,
82
+ themeAppearance,
83
+ themeTokenRgb,
84
+ type ShikiThemeRegistration,
85
+ } from "./theme-colors";
86
+ import {
87
+ activeThemeName,
88
+ requestTerminalColorScheme,
89
+ startFocusResync,
90
+ SYSTEM_THEME_NAME,
91
+ systemThemeAvailable,
92
+ } from "./terminal-theme";
74
93
 
75
94
  const RESET = "\x1b[0m";
76
95
  const TRANSPARENT_BG = "\x1b[49m";
77
96
  const TRANSPARENT_RESET = `${RESET}${TRANSPARENT_BG}`;
78
97
 
79
- // User/code box borders and thinking/thought text: branch color + OUTLINE_CHROME_BRIGHTEN.
80
- // Branch ├└│ stay at `currentToolBranchAnsi` (see syncOutlineChromeFromBranch).
98
+ // User/code box borders and thinking/thought text use the outline chrome color, branch
99
+ // connectors ├└│ the fainter branch color (see chromeAnsi / syncOutlineChromeFromBranch).
81
100
  let BORDER_COLOR = "\x1b[38;5;238m";
82
101
  let CODE_BLOCK_LANG_FG = "\x1b[38;2;95;95;95m";
83
102
  const CHROME_ITALIC = "\x1b[3m";
84
- /** Lift outline chrome above branch connectors so boxes and thought read brighter. */
85
- const OUTLINE_CHROME_BRIGHTEN = 64;
86
103
  const ANSI_RE = /\x1b\[[0-9;]*m/g;
87
104
  const ANSI_PRESENT_RE = /\x1b\[[0-9;]*m/;
88
105
  const PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-container-render");
@@ -137,9 +154,19 @@ interface SettingsFile {
137
154
  * defaults so users keep full control.
138
155
  */
139
156
  themeAdaptive?: boolean;
157
+ /**
158
+ * On by default (pi >= 1.4): pi uses its built-in `system` theme, which takes
159
+ * every color from the terminal's own palette (Ghostty, Rex, ...), and
160
+ * re-reads that palette when the terminal regains focus so same-kind theme
161
+ * switches (dark to dark) are picked up too. Set to false, or run
162
+ * `/cc-theme terminal off`, to keep the theme chosen in pi's /settings.
163
+ */
164
+ terminalTheme?: boolean;
165
+ /** Pi theme that was active before `terminalTheme` was turned on; restored when it is turned off. */
166
+ terminalThemePrevious?: string;
140
167
  /**
141
168
  * Theme color key used for the spinner verb (e.g. "Cooking…"). Defaults
142
- * to "accent". Useful when the active theme's accent is overloaded for
169
+ * to "borderAccent". Useful when the active theme's accent is overloaded for
143
170
  * borders, headings, or bash mode and the verb should pop differently.
144
171
  * Valid keys are any of the pi theme `ThemeColor` names (e.g. accent,
145
172
  * borderAccent, success, warning, mdHeading, thinkingMedium, bashMode).
@@ -157,10 +184,6 @@ interface SettingsFile {
157
184
  * renders expanded, like stock pi.
158
185
  */
159
186
  thinkingMode?: "live" | "full";
160
- /** Gray level 0–255 for ├ └ │ when branch color mode is `fixed`. */
161
- toolBranchRgbGray?: number;
162
- /** `fixed` (default): rgb gray 72, theme-independent. `theme`: dim → muted → borderMuted. */
163
- toolBranchColorMode?: "theme" | "fixed";
164
187
  }
165
188
 
166
189
  let _settingsCache: { value: SettingsFile; timestamp: number } | null = null;
@@ -233,7 +256,10 @@ function syncToolBackgroundMode(): void {
233
256
 
234
257
  function setThemeBg(theme: unknown, key: string, value: string): void {
235
258
  const themeAny = theme as any;
236
- if (themeAny.bgColors instanceof Map) {
259
+ // pi >= 1.4 keeps background escapes in `bgAnsi`; older versions in `bgColors`.
260
+ if (themeAny.bgAnsi instanceof Map) {
261
+ themeAny.bgAnsi.set(key, value);
262
+ } else if (themeAny.bgColors instanceof Map) {
237
263
  themeAny.bgColors.set(key, value);
238
264
  } else if (themeAny.bgColors && typeof themeAny.bgColors === "object") {
239
265
  themeAny.bgColors[key] = value;
@@ -1580,7 +1606,7 @@ const TOOL_EXECUTION_PATCH_FLAG = Symbol.for("pi-claude-style-tools:patched-tool
1580
1606
 
1581
1607
  // Rendered-output cache for assistant/user/custom message components.
1582
1608
  // Keyed by (width, branch visual epoch, tool background mode). The epoch changes on
1583
- // theme / /cc-tools branch / /cc-theme rebinds; the mode is included because subagent
1609
+ // theme and /cc-theme rebinds; the mode is included because subagent
1584
1610
  // (custom-message) framing follows `toolBackgroundMode` via frameToolLikeLines. This avoids
1585
1611
  // re-running the per-line ANSI stripping (applyTerminalCopyZones, normalizeLeadingCheckGlyph,
1586
1612
  // border boxing) on every scroll/expand re-render — the dominant CPU cost on long chats,
@@ -4203,8 +4229,11 @@ function currentThemePaletteFingerprint(): string | undefined {
4203
4229
 
4204
4230
  /** Resolved-color fingerprint so palette re-derives when the active theme file changes under the same name/object. */
4205
4231
  function themePaletteFingerprint(theme: any): string {
4206
- const keys = ["success", "error", "borderMuted", "accent", "muted", "toolDiffAdded", "toolDiffRemoved"] as const;
4207
- return keys.map((k) => safeFgAnsi(theme, k) ?? "").join("\u001f");
4232
+ const keys = ["success", "error", "borderMuted", "accent", "muted", "toolDiffAdded", "toolDiffRemoved", "syntaxKeyword", "syntaxString"] as const;
4233
+ // Tokens set to "" follow the terminal's default colors, which can change under the same theme.
4234
+ const defaults = terminalDefaultColors(theme);
4235
+ const terminal = defaults ? `${rgbToHex(defaults.background)}${rgbToHex(defaults.foreground)}` : "";
4236
+ return [...keys.map((k) => safeFgAnsi(theme, k) ?? ""), themeAppearance(theme) ?? "", terminal].join("\u001f");
4208
4237
  }
4209
4238
 
4210
4239
  function invalidateThemePaletteCache(): void {
@@ -4218,7 +4247,85 @@ function themeAdaptiveEnabled(): boolean {
4218
4247
  return settings.themeAdaptive !== false;
4219
4248
  }
4220
4249
 
4221
- let DIFF_THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
4250
+ // ---------------------------------------------------------------------------
4251
+ // terminalTheme: pi follows the terminal's own palette (Ghostty, Rex, ...).
4252
+ // ---------------------------------------------------------------------------
4253
+
4254
+ /** On by default; `terminalTheme: false` (or `/cc-theme terminal off`) turns it off. */
4255
+ function terminalThemeEnabled(): boolean {
4256
+ return readSettings().terminalTheme !== false;
4257
+ }
4258
+
4259
+ /** `pi --use-theme <name>` picks the theme for this run. */
4260
+ function themeChosenOnCommandLine(): boolean {
4261
+ return process.argv.some((arg) => arg === "--use-theme" || arg.startsWith("--use-theme="));
4262
+ }
4263
+
4264
+ /** Pi's saved `theme` setting (may be a "light/dark" pair), from pi's own settings file. */
4265
+ function readPiThemeSetting(): string | undefined {
4266
+ const agentDir = process.env.PI_CODING_AGENT_DIR || `${process.env.HOME ?? ""}/.pi/agent`;
4267
+ try {
4268
+ const raw = JSON.parse(readFileSync(`${agentDir}/settings.json`, "utf8"));
4269
+ return typeof raw?.theme === "string" && raw.theme ? raw.theme : undefined;
4270
+ } catch {
4271
+ return undefined;
4272
+ }
4273
+ }
4274
+
4275
+ /**
4276
+ * Switch pi to its terminal-palette theme and keep it in sync. Returns false if unavailable.
4277
+ * `reportUnavailable` is off for the default-on startup path, so older pi versions stay quiet.
4278
+ */
4279
+ function applyTerminalTheme(ctx: any, options: { announce: boolean; reportUnavailable: boolean }): boolean {
4280
+ if (!ctx?.hasUI) return false;
4281
+ if (!systemThemeAvailable(ctx.ui)) {
4282
+ if (options.reportUnavailable) {
4283
+ ctx.ui.notify("Terminal colors need pi's built-in `system` theme (pi 1.4 or newer).", "error");
4284
+ }
4285
+ return false;
4286
+ }
4287
+ const current = activeThemeName(ctx.ui);
4288
+ if (current !== SYSTEM_THEME_NAME) {
4289
+ // Remember what to go back to; a "light/dark" pair is kept as the pair.
4290
+ const previous = readPiThemeSetting() ?? current;
4291
+ if (previous && previous !== SYSTEM_THEME_NAME) writeSettingsKey("terminalThemePrevious", previous);
4292
+ const result = ctx.ui.setTheme(SYSTEM_THEME_NAME);
4293
+ if (!result?.success) {
4294
+ ctx.ui.notify(`Could not switch to the terminal theme: ${result?.error ?? "unknown error"}`, "error");
4295
+ return false;
4296
+ }
4297
+ if (options.announce) {
4298
+ // Pi renders a resumed session's transcript right after session_start; post the
4299
+ // notice on the next tick so it lands below that history instead of above it.
4300
+ const message = `Theme follows the terminal's colors (was "${previous ?? current}"). /cc-theme terminal off switches back and keeps it.`;
4301
+ unrefTimer(setTimeout(() => {
4302
+ try {
4303
+ ctx.ui.notify(message, "info");
4304
+ } catch { /* session replaced in the meantime */ }
4305
+ }, 0));
4306
+ }
4307
+ }
4308
+ startFocusResync(ctx.ui, terminalThemeEnabled);
4309
+ return true;
4310
+ }
4311
+
4312
+ /** Turn terminalTheme off and restore the theme that was active before it. */
4313
+ function restorePreviousTheme(ctx: any): string | undefined {
4314
+ const previous = readSettings().terminalThemePrevious;
4315
+ writeSettingsKey("terminalThemePrevious", undefined);
4316
+ if (!ctx?.hasUI || !previous || activeThemeName(ctx.ui) !== SYSTEM_THEME_NAME) return undefined;
4317
+ let name = previous;
4318
+ if (previous.includes("/")) {
4319
+ // ctx.ui.setTheme takes one theme name; pick the pair member for the current appearance.
4320
+ const [light, dark] = previous.split("/");
4321
+ name = themeAppearance(ctx.ui.theme) === "light" ? light : dark;
4322
+ }
4323
+ const result = ctx.ui.setTheme(name);
4324
+ return result?.success ? name : undefined;
4325
+ }
4326
+
4327
+ /** Shiki theme for diff previews: a bundled theme name, or one generated from the pi theme. */
4328
+ let DIFF_THEME: BundledTheme | ShikiThemeRegistration = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
4222
4329
  /** True when the active pi theme has a light panel background (edit/write diff chrome). */
4223
4330
  let _diffOnLightBg = false;
4224
4331
  let codeToAnsiLoader: Promise<any> | null = null;
@@ -4260,50 +4367,53 @@ let FG_DEL = "\x1b[38;2;200;100;100m";
4260
4367
  let FG_DIM = "\x1b[38;2;80;80;80m";
4261
4368
  let FG_LNUM = "\x1b[38;2;100;100;100m";
4262
4369
  let FG_RULE = "\x1b[38;2;50;50;50m";
4263
- // Tool branch connectors (├ └ │). Default fixed gray 72 — independent of pi theme.
4264
- const DEFAULT_TOOL_BRANCH_GRAY = 72;
4370
+ // Branch connectors (├ └ │) and outline chrome (user box, code fences, thinking rule) are neutral
4371
+ // marks drawn at a fixed contrast against the terminal background: the background blended toward
4372
+ // the terminal's text color until it reaches 2:1 for connectors (rgb 72 on a #161616 terminal) and
4373
+ // 5.1:1 for outlines (rgb ~136). That keeps connectors faint but visible on dark, light, and tinted
4374
+ // themes alike, with nothing to configure. When pi does not report the terminal's colors (pi < 1.4),
4375
+ // the same contrasts are taken against plain black or white, per the theme's appearance.
4376
+ type ChromeKind = "branch" | "outline";
4377
+ const CHROME_CONTRAST: Record<ChromeKind, number> = { branch: 2.0, outline: 5.1 };
4378
+ const DARK_FALLBACK_GRAY: Record<ChromeKind, number> = { branch: 72, outline: 136 };
4379
+ const WHITE_RGB = { r: 255, g: 255, b: 255 };
4380
+ const BLACK_RGB = { r: 0, g: 0, b: 0 };
4265
4381
 
4266
4382
  function toolBranchRgbAnsi(gray: number): string {
4267
4383
  const g = Math.max(0, Math.min(255, Math.round(gray)));
4268
4384
  return `\x1b[38;2;${g};${g};${g}m`;
4269
4385
  }
4270
4386
 
4271
- function ansiRgbBrightenedBy(ansi: string, delta: number): string | null {
4272
- const rgb = parseAnsiRgb(ansi);
4273
- if (!rgb) return null;
4274
- const bump = (c: number) => Math.max(0, Math.min(255, Math.round(c + delta)));
4275
- return `\x1b[38;2;${bump(rgb.r)};${bump(rgb.g)};${bump(rgb.b)}m`;
4276
- }
4277
-
4278
- /** Outline chrome always brighter than branch; never falls back to identical branch ANSI. */
4279
- function outlineChromeAnsiFromBranch(theme?: any): string {
4280
- const t = theme ?? _toolBranchThemeHint;
4281
- const branch = currentToolBranchAnsi(t);
4282
- const fromBranch = ansiRgbBrightenedBy(branch, OUTLINE_CHROME_BRIGHTEN);
4283
- if (fromBranch) return fromBranch;
4284
- let gray = DEFAULT_TOOL_BRANCH_GRAY;
4285
- if (toolBranchColorModeFixed()) {
4286
- gray = getConfiguredToolBranchGray();
4287
- } else if (t) {
4288
- const hint = safeFgAnsi(t, "dim") ?? safeFgAnsi(t, "muted") ?? safeFgAnsi(t, "borderMuted");
4289
- const rgb = hint ? parseAnsiRgb(hint) : null;
4290
- if (rgb) gray = Math.round((rgb.r + rgb.g + rgb.b) / 3);
4291
- }
4292
- return toolBranchRgbAnsi(Math.min(255, gray + OUTLINE_CHROME_BRIGHTEN));
4293
- }
4294
-
4295
- function getConfiguredToolBranchGray(): number {
4296
- const raw = readSettings().toolBranchRgbGray;
4297
- return typeof raw === "number" && Number.isFinite(raw) ? Math.max(0, Math.min(255, Math.round(raw))) : DEFAULT_TOOL_BRANCH_GRAY;
4298
- }
4299
-
4300
- function toolBranchColorModeFixed(): boolean {
4301
- return readSettings().toolBranchColorMode !== "theme";
4387
+ // Connectors resolve their color on every rendered row, so keep the solved blends.
4388
+ const _chromeAnsiCache = new Map<string, string>();
4389
+
4390
+ /** Chrome color for `kind` on the current terminal background. */
4391
+ function chromeAnsi(theme: any, kind: ChromeKind): string {
4392
+ const defaults = theme ? terminalDefaultColors(theme) : null;
4393
+ const light = !defaults && !!theme && isLightThemeBackground(theme);
4394
+ const mode = typeof theme?.getColorMode === "function" ? theme.getColorMode() : "truecolor";
4395
+ const key = defaults
4396
+ ? `${kind}|${rgbToHex(defaults.background)}|${rgbToHex(defaults.foreground)}|${mode}`
4397
+ : `${kind}|${light ? "light" : "dark"}|${mode}`;
4398
+ let ansi = _chromeAnsiCache.get(key);
4399
+ if (!ansi) {
4400
+ const contrast = CHROME_CONTRAST[kind];
4401
+ const gray = DARK_FALLBACK_GRAY[kind];
4402
+ const rgb = defaults
4403
+ ? blendForContrast(defaults.background, defaults.foreground, contrast)
4404
+ : light
4405
+ ? blendForContrast(WHITE_RGB, BLACK_RGB, contrast)
4406
+ : { r: gray, g: gray, b: gray };
4407
+ if (_chromeAnsiCache.size > 32) _chromeAnsiCache.clear();
4408
+ ansi = rgbFgAnsi(theme, rgb);
4409
+ _chromeAnsiCache.set(key, ansi);
4410
+ }
4411
+ return ansi;
4302
4412
  }
4303
4413
 
4304
4414
  function toolBranchRenderCacheKey(): string {
4305
- if (toolBranchColorModeFixed()) return `fixed:${getConfiguredToolBranchGray()}`;
4306
- return `theme:${stripAnsi(TOOL_RULE)}`;
4415
+ // The escape itself, not stripAnsi(TOOL_RULE) (which is always ""), so a new color is a new key.
4416
+ return TOOL_RULE;
4307
4417
  }
4308
4418
 
4309
4419
  let _toolBranchVisualEpoch = 0;
@@ -4312,47 +4422,17 @@ function bumpToolBranchVisualEpoch(): void {
4312
4422
  _toolBranchVisualEpoch++;
4313
4423
  }
4314
4424
 
4315
- /** On light panels, theme dim/muted can be nearly white — pull chrome toward mid-gray. */
4316
- function attenuateChromeAnsi(ansi: string, theme: any): string {
4317
- const rgb = parseAnsiRgb(ansi);
4318
- if (!rgb) return ansi;
4319
- if (!isLightThemeBackground(theme)) return ansi;
4320
- const lum = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
4321
- // Already quiet enough on light backgrounds.
4322
- if (lum <= 145) return ansi;
4323
- const target = 118;
4324
- const t = Math.min(1, (lum - 130) / 110);
4325
- const mix = (c: number) => Math.round(c + (target - c) * t);
4326
- return `\x1b[38;2;${mix(rgb.r)};${mix(rgb.g)};${mix(rgb.b)}m`;
4327
- }
4328
-
4329
- /** Shared outline chrome: user box, tool rules, code fences, branch connectors. */
4330
- function resolveThemeChromeFg(theme: any): string | null {
4331
- if (!theme || !themeAdaptiveEnabled()) return null;
4332
- const dim = safeFgAnsi(theme, "dim");
4333
- const muted = safeFgAnsi(theme, "muted");
4334
- const borderMuted = safeFgAnsi(theme, "borderMuted");
4335
- const thinking = safeFgAnsi(theme, "thinkingText");
4336
- const raw = dim ?? muted ?? borderMuted ?? thinking;
4337
- return raw ? attenuateChromeAnsi(raw, theme) : null;
4338
- }
4339
-
4340
- /** Resolve ├ └ │ color from settings + theme on every use (not a stale global). */
4425
+ /** Last theme seen, for call sites that draw connectors without a theme at hand. */
4341
4426
  let _toolBranchThemeHint: any;
4342
4427
 
4428
+ /** ├ └ │ color for the current terminal background (resolved on every use, not a stale global). */
4343
4429
  function currentToolBranchAnsi(theme?: any): string {
4344
- const t = theme ?? _toolBranchThemeHint;
4345
- if (toolBranchColorModeFixed()) {
4346
- return toolBranchRgbAnsi(getConfiguredToolBranchGray());
4347
- }
4348
- const chrome = t ? resolveThemeChromeFg(t) : null;
4349
- if (chrome) return chrome;
4350
- return toolBranchRgbAnsi(getConfiguredToolBranchGray());
4430
+ return chromeAnsi(theme ?? _toolBranchThemeHint, "branch");
4351
4431
  }
4352
4432
 
4353
- /** User box, code fences, thinking/thought: branch + OUTLINE_CHROME_BRIGHTEN (never same as branch). */
4433
+ /** User box, code fences, thinking/thought: the outline chrome color (stronger than connectors). */
4354
4434
  function syncOutlineChromeFromBranch(theme?: any): void {
4355
- const outline = outlineChromeAnsiFromBranch(theme);
4435
+ const outline = chromeAnsi(theme ?? _toolBranchThemeHint, "outline");
4356
4436
  const prevBorder = BORDER_COLOR;
4357
4437
  BORDER_COLOR = outline;
4358
4438
  WORKED_LINE_FG = outline;
@@ -4416,7 +4496,7 @@ function refreshAllToolBranchVisuals(ctx: any): void {
4416
4496
  invalidateThemePaletteCache();
4417
4497
  applyToolBackgroundMode(ctx?.ui?.theme);
4418
4498
  applyToolBranchColor(ctx?.ui?.theme);
4419
- bumpToolBranchVisualEpoch(); // always bust ToolText + container caches after /cc-tools branch
4499
+ bumpToolBranchVisualEpoch(); // always bust ToolText + container caches
4420
4500
  // Tool rows recompute branch markup on next render (liveBranchDisplay + cache bust).
4421
4501
  if (ctx?.hasUI) {
4422
4502
  try {
@@ -4457,7 +4537,7 @@ function scheduleDeferredChromeRebind(ctx: any, delayMs = 0): void {
4457
4537
  unrefTimer(timer);
4458
4538
  }
4459
4539
 
4460
- let TOOL_RULE = toolBranchRgbAnsi(DEFAULT_TOOL_BRANCH_GRAY);
4540
+ let TOOL_RULE = toolBranchRgbAnsi(DARK_FALLBACK_GRAY.branch);
4461
4541
  let FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
4462
4542
  let FG_STRIPE = "\x1b[38;2;40;40;40m";
4463
4543
 
@@ -4492,6 +4572,10 @@ const FALLBACK_BASE_BG_DARK = { r: 32, g: 35, b: 42 };
4492
4572
  const FALLBACK_BASE_BG_LIGHT = { r: 232, g: 233, b: 236 };
4493
4573
 
4494
4574
  function isLightThemeBackground(theme: any): boolean {
4575
+ // pi >= 1.4 decides light/dark itself (from the terminal's colors for the system theme);
4576
+ // use the same answer so diffs, chrome, and pi's own UI never disagree.
4577
+ const appearance = themeAppearance(theme);
4578
+ if (appearance) return appearance === "light";
4495
4579
  const panel =
4496
4580
  themeBgRgb(theme, "toolSuccessBg") ||
4497
4581
  themeBgRgb(theme, "userMessageBg") ||
@@ -4511,10 +4595,18 @@ function isLightThemeBackground(theme: any): boolean {
4511
4595
  function syncDiffShikiTheme(theme: any): void {
4512
4596
  if (process.env.DIFF_THEME) return;
4513
4597
  const config = loadDiffConfig();
4514
- if (config.diffTheme) return;
4598
+ if (config.diffTheme || config.diffColors?.shikiTheme) return;
4515
4599
  _diffOnLightBg = isLightThemeBackground(theme);
4516
- DIFF_THEME = (_diffOnLightBg ? "github-light" : "github-dark") as BundledTheme;
4517
- clearHighlightCache();
4600
+ // Highlight diffs with the theme's own syntax colors (the ones pi uses for Markdown code
4601
+ // blocks). GitHub's palette is the fallback for pi versions without concrete theme colors.
4602
+ const fromTheme = themeAdaptiveEnabled() ? shikiThemeFromPiTheme(theme) : null;
4603
+ const next = fromTheme ?? ((_diffOnLightBg ? "github-light" : "github-dark") as BundledTheme);
4604
+ if (diffThemeName(next) !== diffThemeName(DIFF_THEME)) clearHighlightCache();
4605
+ DIFF_THEME = next;
4606
+ }
4607
+
4608
+ function diffThemeName(theme: BundledTheme | ShikiThemeRegistration): string {
4609
+ return typeof theme === "string" ? theme : theme.name;
4518
4610
  }
4519
4611
  const UNIVERSAL_DIFF_ADD_FG = { r: 110, g: 210, b: 130 };
4520
4612
  const UNIVERSAL_DIFF_DEL_FG = { r: 225, g: 110, b: 110 };
@@ -4535,17 +4627,65 @@ function rgbToBgAnsi(c: { r: number; g: number; b: number }): string {
4535
4627
  return `\x1b[48;2;${Math.round(c.r)};${Math.round(c.g)};${Math.round(c.b)}m`;
4536
4628
  }
4537
4629
 
4630
+ // Diff row tints: how far each background moves from the surface toward the theme's diff color.
4631
+ // Light themes need less, because the same share of a mid-tone color reads stronger on white.
4632
+ const DIFF_TINT = {
4633
+ dark: { line: { add: 0.2, del: 0.17 }, word: { add: 0.36, del: 0.32 }, gutter: { add: 0.12, del: 0.1 } },
4634
+ light: { line: { add: 0.16, del: 0.13 }, word: { add: 0.3, del: 0.26 }, gutter: { add: 0.1, del: 0.08 } },
4635
+ } as const;
4636
+
4637
+ /** The color a diff row is drawn on: the terminal background, or pi's tool panel in `default` tool style. */
4638
+ function diffSurfaceRgb(theme: any): Rgb | null {
4639
+ if (toolBackgroundMode === "default") {
4640
+ const panel = themeTokenRgb(theme, "toolSuccessBg");
4641
+ if (panel) return panel;
4642
+ }
4643
+ return terminalDefaultColors(theme)?.background ?? null;
4644
+ }
4645
+
4646
+ /**
4647
+ * Diff colors straight from the theme: text in `toolDiffAdded` / `toolDiffRemoved`, and
4648
+ * backgrounds that tint the row's real surface toward those same colors. Returns false on pi
4649
+ * versions without concrete theme colors.
4650
+ */
4651
+ function deriveDiffPaletteFromThemeColors(theme: any): boolean {
4652
+ const add = themeTokenRgb(theme, "toolDiffAdded");
4653
+ const del = themeTokenRgb(theme, "toolDiffRemoved");
4654
+ const surface = diffSurfaceRgb(theme);
4655
+ if (!add || !del || !surface) return false;
4656
+ const tint = DIFF_TINT[isLightThemeBackground(theme) ? "light" : "dark"];
4657
+ const bg = (color: Rgb, amount: number) => rgbBgAnsi(theme, mixThemeRgb(surface, color, amount));
4658
+ FG_ADD = rgbFgAnsi(theme, add);
4659
+ FG_DEL = rgbFgAnsi(theme, del);
4660
+ BG_ADD = bg(add, tint.line.add);
4661
+ BG_DEL = bg(del, tint.line.del);
4662
+ BG_ADD_W = bg(add, tint.word.add);
4663
+ BG_DEL_W = bg(del, tint.word.del);
4664
+ BG_GUTTER_ADD = bg(add, tint.gutter.add);
4665
+ BG_GUTTER_DEL = bg(del, tint.gutter.del);
4666
+ return true;
4667
+ }
4668
+
4538
4669
  function autoDeriveBgFromTheme(theme: any): void {
4539
4670
  // Diff palette derivation.
4540
4671
  //
4541
4672
  // `toolDiffAdded` / `toolDiffRemoved` from the active pi theme give us the
4542
- // fg accents. The base background is taken from `toolSuccessBg` (close to
4543
- // the panel color the row will sit on) so the tinted backgrounds blend in
4544
- // instead of forcing a hardcoded dark hue. Falls back to the universal
4545
- // dark palette when the theme is unavailable or themeAdaptive=false.
4673
+ // fg accents. On pi >= 1.4 the backgrounds tint the row's real surface toward
4674
+ // those colors (deriveDiffPaletteFromThemeColors). Older pi versions only
4675
+ // expose ANSI escapes, so the base is taken from `toolSuccessBg` and mixed
4676
+ // with fixed tint targets. Falls back to the universal dark palette when the
4677
+ // theme is unavailable or themeAdaptive=false.
4546
4678
  const useTheme = themeAdaptiveEnabled() && theme;
4547
4679
  const onLight = useTheme && isLightThemeBackground(theme);
4548
4680
  _diffOnLightBg = !!onLight;
4681
+ if (useTheme && deriveDiffPaletteFromThemeColors(theme)) {
4682
+ BG_EMPTY = TRANSPARENT_BG;
4683
+ BG_BASE = TRANSPARENT_BG;
4684
+ D_RST = TRANSPARENT_RESET;
4685
+ DIVIDER = `${FG_RULE}│${D_RST}`;
4686
+ DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
4687
+ return;
4688
+ }
4549
4689
  const addFgRgb = (useTheme && themeFgRgb(theme, "toolDiffAdded")) || UNIVERSAL_DIFF_ADD_FG;
4550
4690
  const delFgRgb = (useTheme && themeFgRgb(theme, "toolDiffRemoved")) || UNIVERSAL_DIFF_DEL_FG;
4551
4691
  const base =
@@ -4581,7 +4721,7 @@ const _claudeStyleDefaults = {
4581
4721
  BORDER_COLOR: "\x1b[38;5;238m",
4582
4722
  WORKED_LINE_FG: "\x1b[38;2;140;140;140m",
4583
4723
  CODE_BLOCK_LANG_FG: "\x1b[38;2;95;95;95m",
4584
- TOOL_RULE: toolBranchRgbAnsi(DEFAULT_TOOL_BRANCH_GRAY),
4724
+ TOOL_RULE: toolBranchRgbAnsi(DARK_FALLBACK_GRAY.branch),
4585
4725
  FG_DIM: "\x1b[38;2;80;80;80m",
4586
4726
  FG_LNUM: "\x1b[38;2;100;100;100m",
4587
4727
  FG_RULE: "\x1b[38;2;50;50;50m",
@@ -4634,7 +4774,12 @@ function applyThemePaletteIfNeeded(theme: any): void {
4634
4774
  }
4635
4775
  const paletteChanged =
4636
4776
  _themePaletteCacheName !== themeName || _themePaletteCacheFingerprint !== fingerprint;
4637
- if (paletteChanged) bumpToolBranchVisualEpoch();
4777
+ if (paletteChanged) {
4778
+ bumpToolBranchVisualEpoch();
4779
+ // A new theme object (e.g. the system theme rebuilt after a terminal theme change)
4780
+ // comes with pi's own tool backgrounds; re-apply the configured tool style to it.
4781
+ applyToolBackgroundMode(theme);
4782
+ }
4638
4783
  _themePaletteCacheTheme = theme;
4639
4784
  _themePaletteCacheName = themeName;
4640
4785
  _themePaletteCacheFingerprint = fingerprint;
@@ -5103,7 +5248,7 @@ function lang(filePath: string): BundledLanguage | undefined {
5103
5248
  return EXT_LANG[extname(filePath).slice(1).toLowerCase()];
5104
5249
  }
5105
5250
 
5106
- async function codeToAnsiLazy(code: string, language: BundledLanguage, theme: BundledTheme): Promise<string> {
5251
+ async function codeToAnsiLazy(code: string, language: BundledLanguage, theme: BundledTheme | ShikiThemeRegistration): Promise<string> {
5107
5252
  if (!codeToAnsiLoader) {
5108
5253
  // Self-healing: a failed import (missing dep, transient error) must not leave a
5109
5254
  // permanently-rejected promise that later becomes an unhandled rejection. Reset
@@ -5140,11 +5285,15 @@ function touchCache(key: string, value: string[]): string[] {
5140
5285
  async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
5141
5286
  if (!code) return [""];
5142
5287
  if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
5143
- const key = `${DIFF_THEME}\0${language}\0${code}`;
5288
+ const diffTheme = DIFF_THEME;
5289
+ const key = `${diffThemeName(diffTheme)}\0${language}\0${code}`;
5144
5290
  const hit = hlCache.get(key);
5145
5291
  if (hit) return touchCache(key, hit);
5146
5292
  try {
5147
- const ansi = normalizeShikiContrast(await codeToAnsiLazy(code, language, DIFF_THEME));
5293
+ const raw = await codeToAnsiLazy(code, language, diffTheme as any);
5294
+ // Themes generated from pi already meet pi's contrast rules for this background;
5295
+ // only bundled themes need their too-dark / too-light colors muted.
5296
+ const ansi = typeof diffTheme === "string" ? normalizeShikiContrast(raw) : raw;
5148
5297
  const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
5149
5298
  return touchCache(key, out);
5150
5299
  } catch {
@@ -7069,7 +7218,7 @@ export default function (pi: ExtensionAPI) {
7069
7218
  // /cc-tools command — control tool chrome, grouping, and detail level.
7070
7219
  const TOOL_MODES = ["outlines", "transparent", "default"] as const;
7071
7220
  const TOOL_BOOL_MODES = ["on", "off", "toggle", "status"] as const;
7072
- const TOOL_SUBCOMMANDS = [...TOOL_MODES, "group", "detail", "thinking", "branch", "status"] as const;
7221
+ const TOOL_SUBCOMMANDS = [...TOOL_MODES, "group", "detail", "thinking", "status"] as const;
7073
7222
  const booleanMode = (raw: string | undefined, current: boolean): boolean | "status" | undefined => {
7074
7223
  const mode = raw || "toggle";
7075
7224
  if (mode === "on") return true;
@@ -7080,22 +7229,11 @@ export default function (pi: ExtensionAPI) {
7080
7229
  };
7081
7230
  const notifyToolStatus = (ctx: any): void => {
7082
7231
  if (!ctx.hasUI) return;
7083
- const branchMode = toolBranchColorModeFixed() ? "fixed" : "theme";
7084
- const branchGray = getConfiguredToolBranchGray();
7085
- const theme = ctx.ui?.theme;
7086
- const chromeHint = branchMode === "theme" && theme
7087
- ? (resolveThemeChromeFg(theme) ? " (attenuated on light themes)" : " (fallback gray if theme keys missing)")
7088
- : "";
7089
- const branchLine = branchMode === "fixed"
7090
- ? `Branch color: fixed rgb(${branchGray})`
7091
- : `Branch color: theme${chromeHint}`;
7092
7232
  ctx.ui.notify([
7093
7233
  `Tool style: ${toolBackgroundMode}`,
7094
7234
  `Tool grouping: ${toolGroupingEnabled() ? "on" : "off"}`,
7095
7235
  `Thinking: ${getThinkingMode()}`,
7096
7236
  `Extra detail: ${extraToolOutputExpanded ? "on" : "off"} (${rawKeyHint("ctrl+shift+o", "toggle")})`,
7097
- branchLine,
7098
- ` /cc-tools branch <0-255> | theme | fixed | reset`,
7099
7237
  ].join("\n"), "info");
7100
7238
  };
7101
7239
  pi.registerCommand("cc-tools", {
@@ -7113,20 +7251,12 @@ export default function (pi: ExtensionAPI) {
7113
7251
  m === "group" ? "Toggle grouped adjacent/concurrent tool rows"
7114
7252
  : m === "thinking" ? "Thinking display: live (default) or full"
7115
7253
  : m === "detail" ? "Toggle Ctrl+Shift+O extra-detail mode"
7116
- : m === "branch" ? "├ └ │ gray (0-255), theme, fixed, or reset"
7117
7254
  : m === "status" ? "Show tool UI settings"
7118
7255
  : m === "outlines" ? "Horizontal rules around each tool (default)"
7119
7256
  : m === "transparent" ? "No borders or backgrounds"
7120
7257
  : "Pi built-in tool backgrounds",
7121
7258
  }));
7122
7259
  }
7123
- if (first === "branch") {
7124
- const second = parts[1] ?? "";
7125
- const opts = ["theme", "fixed", "reset", "status"];
7126
- return opts
7127
- .filter((o) => o.startsWith(second))
7128
- .map((o) => ({ value: `branch ${o}`, label: o, description: "Branch connector color" }));
7129
- }
7130
7260
  if (first === "thinking") {
7131
7261
  const second = parts[1] ?? "";
7132
7262
  return ["live", "full", "status"]
@@ -7168,42 +7298,6 @@ export default function (pi: ExtensionAPI) {
7168
7298
  return;
7169
7299
  }
7170
7300
 
7171
- if (sub === "branch") {
7172
- const arg = parts[1] ?? "status";
7173
- if (arg === "status" || !arg) {
7174
- notifyToolStatus(ctx);
7175
- return;
7176
- }
7177
- if (arg === "reset") {
7178
- writeSettingsKey("toolBranchRgbGray", undefined);
7179
- writeSettingsKey("toolBranchColorMode", undefined);
7180
- if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7181
- if (ctx.hasUI) ctx.ui.notify(`Branch color → fixed rgb(${DEFAULT_TOOL_BRANCH_GRAY}) (default)`, "info");
7182
- return;
7183
- }
7184
- if (arg === "theme") {
7185
- writeSettingsKey("toolBranchColorMode", "theme");
7186
- if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7187
- if (ctx.hasUI) ctx.ui.notify("Branch color → follow pi theme (dim/muted)", "info");
7188
- return;
7189
- }
7190
- if (arg === "fixed") {
7191
- writeSettingsKey("toolBranchColorMode", "fixed");
7192
- if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7193
- if (ctx.hasUI) ctx.ui.notify(`Branch color → fixed rgb(${getConfiguredToolBranchGray()})`, "info");
7194
- return;
7195
- }
7196
- const gray = Number.parseInt(arg, 10);
7197
- if (!Number.isFinite(gray) || gray < 0 || gray > 255) {
7198
- if (ctx.hasUI) ctx.ui.notify("Usage: /cc-tools branch <0-255> | theme | fixed | reset", "error");
7199
- return;
7200
- }
7201
- writeSettingsKey("toolBranchRgbGray", gray);
7202
- writeSettingsKey("toolBranchColorMode", "fixed");
7203
- if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7204
- if (ctx.hasUI) ctx.ui.notify(`Branch color → fixed rgb(${gray})`, "info");
7205
- return;
7206
- }
7207
7301
 
7208
7302
  if (sub === "thinking") {
7209
7303
  const arg = (parts[1] ?? "status").toLowerCase();
@@ -7255,11 +7349,69 @@ export default function (pi: ExtensionAPI) {
7255
7349
  },
7256
7350
  });
7257
7351
 
7258
- // /cc-theme command — toggle pi-theme-adaptive coloring at runtime.
7259
- const THEME_MODES = ["on", "off", "toggle", "status"] as const;
7352
+ // /cc-theme command — toggle pi-theme-adaptive coloring at runtime, and
7353
+ // /cc-theme terminal — make pi's theme follow the terminal's palette.
7354
+ const THEME_MODES = ["on", "off", "toggle", "status", "terminal"] as const;
7355
+ const TERMINAL_MODES = ["on", "off", "toggle", "status"] as const;
7356
+ const handleTerminalTheme = (arg: string, ctx: any): void => {
7357
+ const current = terminalThemeEnabled();
7358
+ if (!arg || arg === "status") {
7359
+ if (!ctx.hasUI) return;
7360
+ const name = activeThemeName(ctx.ui) ?? "unknown";
7361
+ const previous = readSettings().terminalThemePrevious;
7362
+ const isDefault = readSettings().terminalTheme === undefined;
7363
+ ctx.ui.notify([
7364
+ `Terminal colors: ${current ? "on" : "off"}${isDefault ? " (default)" : ""} (pi theme "${name}")`,
7365
+ current && previous ? ` /cc-theme terminal off restores "${previous}"` : "",
7366
+ current && name !== SYSTEM_THEME_NAME ? " pi is not using the system theme right now; /cc-theme terminal on switches it back" : "",
7367
+ ].filter(Boolean).join("\n"), "info");
7368
+ return;
7369
+ }
7370
+ let next: boolean;
7371
+ if (arg === "on") next = true;
7372
+ else if (arg === "off") next = false;
7373
+ else if (arg === "toggle") next = !current;
7374
+ else {
7375
+ if (ctx.hasUI) ctx.ui.notify(`Usage: /cc-theme terminal ${TERMINAL_MODES.join("|")}`, "error");
7376
+ return;
7377
+ }
7378
+ if (next) {
7379
+ const prior = readSettings().terminalTheme;
7380
+ writeSettingsKey("terminalTheme", true);
7381
+ if (!applyTerminalTheme(ctx, { announce: false, reportUnavailable: true })) {
7382
+ writeSettingsKey("terminalTheme", prior);
7383
+ return;
7384
+ }
7385
+ requestTerminalColorScheme();
7386
+ if (ctx.hasUI) ctx.ui.notify("Terminal colors: on — pi's theme now follows the terminal (Ghostty, Rex, ...)", "info");
7387
+ } else {
7388
+ // Explicit false: unset means on (the default).
7389
+ writeSettingsKey("terminalTheme", false);
7390
+ const restored = restorePreviousTheme(ctx);
7391
+ if (ctx.hasUI) {
7392
+ ctx.ui.notify(restored
7393
+ ? `Terminal colors: off — restored theme "${restored}"`
7394
+ : "Terminal colors: off — pick a theme with /settings", "info");
7395
+ }
7396
+ }
7397
+ };
7260
7398
  pi.registerCommand("cc-theme", {
7261
- description: "Toggle whether tool borders / branch rules / diff colors follow the active pi theme",
7399
+ description: "Follow the terminal's colors (terminal on|off), or toggle whether tool chrome / diff colors follow the pi theme",
7262
7400
  getArgumentCompletions(prefix) {
7401
+ if (prefix.startsWith("terminal ")) {
7402
+ const second = prefix.slice("terminal ".length);
7403
+ return TERMINAL_MODES
7404
+ .filter((m) => m.startsWith(second))
7405
+ .map((m) => ({
7406
+ value: `terminal ${m}`,
7407
+ label: m,
7408
+ description:
7409
+ m === "on" ? "Use pi's system theme, built from the terminal's palette, and re-read it on focus"
7410
+ : m === "off" ? "Go back to the theme you had before"
7411
+ : m === "toggle" ? "Flip between on and off"
7412
+ : "Show whether pi follows the terminal's colors",
7413
+ }));
7414
+ }
7263
7415
  return THEME_MODES
7264
7416
  .filter((m) => m.startsWith(prefix))
7265
7417
  .map((m) => ({
@@ -7269,11 +7421,16 @@ export default function (pi: ExtensionAPI) {
7269
7421
  m === "on" ? "Derive borders, branch rules, dim text and diff tints from the active pi theme (default)"
7270
7422
  : m === "off" ? "Keep the fixed Claude-style palette regardless of theme"
7271
7423
  : m === "toggle" ? "Flip between on and off"
7424
+ : m === "terminal" ? "Make pi's theme follow the terminal's colors (Ghostty, Rex, ...)"
7272
7425
  : "Show the current setting and a preview of the derived colors",
7273
7426
  }));
7274
7427
  },
7275
7428
  async handler(args, ctx) {
7276
7429
  const raw = args.trim().toLowerCase();
7430
+ if (raw === "terminal" || raw.startsWith("terminal ")) {
7431
+ handleTerminalTheme(raw.slice("terminal".length).trim(), ctx);
7432
+ return;
7433
+ }
7277
7434
  const current = themeAdaptiveEnabled();
7278
7435
 
7279
7436
  if (!raw || raw === "status") {
@@ -7281,26 +7438,26 @@ export default function (pi: ExtensionAPI) {
7281
7438
  const theme = ctx.ui.theme as any;
7282
7439
  const themeName = theme?.name ?? "unknown";
7283
7440
  const state = current ? "on" : "off";
7441
+ const terminalLine = `terminal colors: ${terminalThemeEnabled() ? "on" : "off"} (/cc-theme terminal on|off)`;
7284
7442
  if (raw === "status" && current) {
7285
7443
  const settings = readSettings();
7286
7444
  const verbKey = settings.spinnerVerbColor || "borderAccent";
7287
7445
  const statusKey = settings.spinnerStatusColor || "muted";
7288
7446
  const verbAnsi = safeFgAnsi(theme, verbKey) ?? safeFgAnsi(theme, "accent");
7289
7447
  const statusAnsi = safeFgAnsi(theme, statusKey) ?? safeFgAnsi(theme, "muted");
7290
- const chromePreview = resolveThemeChromeFg(theme);
7291
7448
  // Print a short preview of what we derived.
7292
7449
  const preview = [
7293
- `chrome : ${chromePreview ? `${chromePreview}─┌ User ├─\x1b[39m` : "(unchanged)"}`,
7294
- ` (user box, tool rules, branches)`,
7450
+ `outlines : ${chromeAnsi(theme, "outline")}╭─ User ──\x1b[39m (user box, code fences)`,
7451
+ `connectors : ${chromeAnsi(theme, "branch")}├ └ │\x1b[39m (faint, fixed contrast on the terminal background)`,
7295
7452
  `muted text : ${safeFgAnsi(theme, "muted") ? `${safeFgAnsi(theme, "muted")}example dim text\x1b[39m` : "(unchanged)"}`,
7296
7453
  `diff add : ${safeFgAnsi(theme, "toolDiffAdded") ? `${safeFgAnsi(theme, "toolDiffAdded")}+ added line\x1b[39m` : "(unchanged)"}`,
7297
7454
  `diff del : ${safeFgAnsi(theme, "toolDiffRemoved") ? `${safeFgAnsi(theme, "toolDiffRemoved")}- removed line\x1b[39m` : "(unchanged)"}`,
7298
7455
  `spinner verb: ${verbAnsi ? `${verbAnsi}Cooking…\x1b[39m` : "(unchanged)"} (key: ${verbKey})`,
7299
7456
  `spinner stat: ${statusAnsi ? `${statusAnsi}(thinking · ↓ 10 tokens · 2s)\x1b[39m` : "(unchanged)"} (key: ${statusKey})`,
7300
7457
  ].join("\n ");
7301
- ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")\n ${preview}`, "info");
7458
+ ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")\n ${terminalLine}\n ${preview}`, "info");
7302
7459
  } else {
7303
- ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")`, "info");
7460
+ ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")\n ${terminalLine}`, "info");
7304
7461
  }
7305
7462
  return;
7306
7463
  }
@@ -7434,6 +7591,11 @@ export default function (pi: ExtensionAPI) {
7434
7591
  clearRtkRewriteState();
7435
7592
  if (!ctx.hasUI) return;
7436
7593
  patchUiNotifications(ctx.ui);
7594
+ // terminalTheme (on by default): switch to, and stay on, the theme built from the
7595
+ // terminal's palette. `pi --use-theme <name>` still wins for that run.
7596
+ if (terminalThemeEnabled() && !(themeChosenOnCommandLine() && activeThemeName(ctx.ui) !== SYSTEM_THEME_NAME)) {
7597
+ applyTerminalTheme(ctx, { announce: true, reportUnavailable: readSettings().terminalTheme === true });
7598
+ }
7437
7599
  // Session switch (/resume, /new) can leave tool chrome from the previous
7438
7600
  // theme; rebind from ctx.ui.theme (other extensions may setTheme in the
7439
7601
  // same tick — deferred passes pick up the final theme without coupling).
@@ -0,0 +1,132 @@
1
+ // Follow the terminal's own colors (Ghostty, Rex, kitty, WezTerm, ...).
2
+ //
3
+ // Pi's built-in `system` theme already builds every color from the palette the
4
+ // terminal reports (OSC 4/10/11) and rebuilds it when the terminal announces a
5
+ // light/dark switch (DEC mode 2031). Terminals announce nothing when you switch
6
+ // between two themes of the same kind, for example from one dark Rex theme to
7
+ // another. So whenever the terminal regains focus (you went to its settings and
8
+ // came back) we ask it for its color scheme (DSR 996). Pi's own handler treats
9
+ // the reply like a light/dark report and re-reads the palette, and it only
10
+ // re-renders when the colors actually changed.
11
+
12
+ const FOCUS_IN = "\x1b[I";
13
+ const FOCUS_OUT = "\x1b[O";
14
+ const ENABLE_FOCUS_REPORTING = "\x1b[?1004h";
15
+ const DISABLE_FOCUS_REPORTING = "\x1b[?1004l";
16
+ const REQUEST_COLOR_SCHEME = "\x1b[?996n";
17
+ const MIN_REQUEST_INTERVAL_MS = 250;
18
+
19
+ export const SYSTEM_THEME_NAME = "system";
20
+
21
+ export interface TerminalIo {
22
+ /** Raw terminal input. Defaults to process.stdin. */
23
+ input: { on(event: "data", listener: (chunk: unknown) => void): unknown };
24
+ /** Writes escape sequences to the terminal. Defaults to process.stdout when it is a TTY. */
25
+ write(data: string): void;
26
+ }
27
+
28
+ interface FocusResyncState {
29
+ io?: TerminalIo;
30
+ stdinListener?: (chunk: unknown) => void;
31
+ unsubscribeInput?: () => void;
32
+ exitHook?: () => void;
33
+ lastRequestAt: number;
34
+ isEnabled: () => boolean;
35
+ }
36
+
37
+ // Kept on globalThis so /reload (which re-evaluates this module) replaces the
38
+ // previous listeners instead of stacking them.
39
+ const STATE_KEY = Symbol.for("pi-claude-style-tools:terminal-theme-focus");
40
+
41
+ function state(): FocusResyncState {
42
+ const g = globalThis as any;
43
+ if (!g[STATE_KEY]) g[STATE_KEY] = { lastRequestAt: 0, isEnabled: () => false } satisfies FocusResyncState;
44
+ return g[STATE_KEY] as FocusResyncState;
45
+ }
46
+
47
+ const processIo: TerminalIo = {
48
+ input: process.stdin,
49
+ write(data: string): void {
50
+ try {
51
+ if (process.stdout.isTTY) process.stdout.write(data);
52
+ } catch { /* terminal gone */ }
53
+ },
54
+ };
55
+
56
+ function write(data: string): void {
57
+ (state().io ?? processIo).write(data);
58
+ }
59
+
60
+ /** Ask the terminal to report its light/dark scheme; pi re-reads the palette on the reply. */
61
+ export function requestTerminalColorScheme(): void {
62
+ const s = state();
63
+ const now = Date.now();
64
+ if (now - s.lastRequestAt < MIN_REQUEST_INTERVAL_MS) return;
65
+ s.lastRequestAt = now;
66
+ write(REQUEST_COLOR_SCHEME);
67
+ }
68
+
69
+ /**
70
+ * Re-read the terminal palette whenever the terminal regains focus.
71
+ *
72
+ * Fullscreen pi already turns on focus reporting and consumes the focus events
73
+ * before extensions see them, so focus-in is detected by watching stdin
74
+ * directly (without consuming anything). In regular mode we turn focus
75
+ * reporting on ourselves and consume the events so they never reach the editor.
76
+ */
77
+ export function startFocusResync(ui: any, isEnabled: () => boolean, io: TerminalIo = processIo): void {
78
+ const s = state();
79
+ s.isEnabled = isEnabled;
80
+ if (s.io && s.io !== io) {
81
+ // A different terminal (tests): start over instead of mixing listeners.
82
+ s.stdinListener = undefined;
83
+ s.exitHook = undefined;
84
+ }
85
+ s.io = io;
86
+
87
+ if (!s.stdinListener) {
88
+ s.stdinListener = (chunk: unknown) => {
89
+ const text = typeof chunk === "string" ? chunk : Buffer.isBuffer(chunk) ? chunk.toString("latin1") : "";
90
+ if (text.includes(FOCUS_IN) && state().isEnabled()) requestTerminalColorScheme();
91
+ };
92
+ io.input.on("data", s.stdinListener);
93
+ }
94
+
95
+ // Pi drops extension input listeners on /reload and session replacement; re-register each time.
96
+ s.unsubscribeInput?.();
97
+ s.unsubscribeInput = undefined;
98
+ if (typeof ui?.onTerminalInput === "function") {
99
+ try {
100
+ s.unsubscribeInput = ui.onTerminalInput((data: string) =>
101
+ data === FOCUS_IN || data === FOCUS_OUT ? { consume: true } : undefined,
102
+ );
103
+ } catch { /* not interactive */ }
104
+ }
105
+
106
+ write(ENABLE_FOCUS_REPORTING);
107
+ if (!s.exitHook && io === processIo) {
108
+ s.exitHook = () => write(DISABLE_FOCUS_REPORTING);
109
+ process.once("exit", s.exitHook);
110
+ }
111
+ }
112
+
113
+ /** Theme name pi currently uses ("system" when it follows the terminal). */
114
+ export function activeThemeName(ui: any): string | undefined {
115
+ try {
116
+ const name = ui?.theme?.name;
117
+ return typeof name === "string" ? name : undefined;
118
+ } catch {
119
+ return undefined;
120
+ }
121
+ }
122
+
123
+ /** True when this pi version has the built-in terminal-palette theme. */
124
+ export function systemThemeAvailable(ui: any): boolean {
125
+ try {
126
+ if (activeThemeName(ui) === SYSTEM_THEME_NAME) return true;
127
+ const themes = ui?.getAllThemes?.();
128
+ return Array.isArray(themes) && themes.some((t: any) => t?.name === SYSTEM_THEME_NAME);
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
@@ -0,0 +1,242 @@
1
+ // Concrete colors from the active pi theme, and a Shiki theme built from them.
2
+ //
3
+ // Pi >= 1.4 exposes `theme.appearance` ("dark" | "light") and `theme.colors`
4
+ // (concrete colors for every token, with tokens set to "" filled from the
5
+ // terminal's reported default colors). Older pi versions only expose ANSI
6
+ // escapes, so every helper here returns null when that API is missing and the
7
+ // callers fall back to parsing `getFgAnsi()` / `getBgAnsi()`.
8
+
9
+ import * as PiTui from "@earendil-works/pi-tui";
10
+
11
+ export type Rgb = { r: number; g: number; b: number };
12
+
13
+ export function rgbToHex({ r, g, b }: Rgb): string {
14
+ const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
15
+ return `#${h(r)}${h(g)}${h(b)}`;
16
+ }
17
+
18
+ /** Linear sRGB mix: `amount` 0 returns `a`, 1 returns `b`. */
19
+ export function mixRgb(a: Rgb, b: Rgb, amount: number): Rgb {
20
+ return {
21
+ r: a.r + (b.r - a.r) * amount,
22
+ g: a.g + (b.g - a.g) * amount,
23
+ b: a.b + (b.b - a.b) * amount,
24
+ };
25
+ }
26
+
27
+ function relativeLuminance({ r, g, b }: Rgb): number {
28
+ const lin = (c: number) => {
29
+ const v = c / 255;
30
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
31
+ };
32
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
33
+ }
34
+
35
+ /** WCAG contrast ratio, 1-21. */
36
+ export function contrastRatio(a: Rgb, b: Rgb): number {
37
+ const la = relativeLuminance(a);
38
+ const lb = relativeLuminance(b);
39
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
40
+ }
41
+
42
+ /**
43
+ * The color between `background` and `foreground` that has `ratio` contrast
44
+ * against the background, so chrome keeps the same visual weight on dark,
45
+ * light, and low-contrast themes. Returns the foreground if it cannot reach it.
46
+ */
47
+ export function blendForContrast(background: Rgb, foreground: Rgb, ratio: number): Rgb {
48
+ if (contrastRatio(background, foreground) <= ratio) return foreground;
49
+ let lo = 0;
50
+ let hi = 1;
51
+ for (let i = 0; i < 18; i++) {
52
+ const mid = (lo + hi) / 2;
53
+ if (contrastRatio(background, mixRgb(background, foreground, mid)) < ratio) lo = mid;
54
+ else hi = mid;
55
+ }
56
+ return mixRgb(background, foreground, hi);
57
+ }
58
+
59
+ /** Pi Color value ({ kind: "rgb" | "oklch" | "indexed", ... }) to RGB. */
60
+ function colorValueToRgb(color: any): Rgb | null {
61
+ if (!color || typeof color !== "object") return null;
62
+ const toRgb = (PiTui as any).colorToRgb;
63
+ if (typeof toRgb === "function") {
64
+ try {
65
+ const rgb = toRgb(color);
66
+ if (rgb && Number.isFinite(rgb.r)) return { r: rgb.r, g: rgb.g, b: rgb.b };
67
+ } catch { /* fall through */ }
68
+ }
69
+ if (color.kind === "rgb" && Number.isFinite(color.r)) return { r: color.r, g: color.g, b: color.b };
70
+ return null;
71
+ }
72
+
73
+ /** Concrete RGB for a theme token, or null on pi versions without `theme.colors`. */
74
+ export function themeTokenRgb(theme: any, token: string): Rgb | null {
75
+ try {
76
+ return colorValueToRgb(theme?.colors?.[token]);
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /** "light" / "dark" as pi decides it, or undefined on pi versions without `theme.appearance`. */
83
+ export function themeAppearance(theme: any): "light" | "dark" | undefined {
84
+ try {
85
+ const appearance = theme?.appearance;
86
+ return appearance === "light" || appearance === "dark" ? appearance : undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ // A copy of the active theme with every token set to "" (terminal default).
93
+ // Pi fills those from the terminal's reported default colors, which is the
94
+ // only public way to read the terminal background. Built from the active
95
+ // theme's own class so it reads the same module state as pi.
96
+ const probeCache = new WeakMap<object, any>();
97
+
98
+ function terminalDefaultsProbe(theme: any): any | null {
99
+ try {
100
+ const fg = theme?.fgAnsi;
101
+ const bg = theme?.bgAnsi;
102
+ const Ctor = theme?.constructor;
103
+ if (!(fg instanceof Map) || !(bg instanceof Map) || typeof Ctor !== "function") return null;
104
+ const key = fg as object;
105
+ let probe = probeCache.get(key);
106
+ if (!probe) {
107
+ const blank = (map: Map<string, unknown>) => Object.fromEntries([...map.keys()].map((k) => [k, ""]));
108
+ const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor";
109
+ probe = new Ctor(blank(fg), blank(bg), mode, { appearance: themeAppearance(theme) });
110
+ probeCache.set(key, probe);
111
+ }
112
+ return probe;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * The terminal's default background and foreground as pi knows them (reported
120
+ * by the terminal, or pi's guess for the theme's appearance). Null on pi
121
+ * versions without `theme.colors`.
122
+ */
123
+ export function terminalDefaultColors(theme: any): { background: Rgb; foreground: Rgb } | null {
124
+ const probe = terminalDefaultsProbe(theme);
125
+ if (!probe) return null;
126
+ const background = themeTokenRgb(probe, "userMessageBg");
127
+ const foreground = themeTokenRgb(probe, "text");
128
+ return background && foreground ? { background, foreground } : null;
129
+ }
130
+
131
+ /** ANSI foreground escape for an RGB color in the theme's color mode (truecolor or 256). */
132
+ export function rgbFgAnsi(theme: any, c: Rgb): string {
133
+ const mode = typeof theme?.getColorMode === "function" ? theme.getColorMode() : "truecolor";
134
+ const fgAnsi = (PiTui as any).foregroundAnsi;
135
+ const rgbColor = (PiTui as any).rgbColor;
136
+ if (mode !== "truecolor" && typeof fgAnsi === "function" && typeof rgbColor === "function") {
137
+ try {
138
+ return fgAnsi(rgbColor(Math.round(c.r), Math.round(c.g), Math.round(c.b)), mode);
139
+ } catch { /* fall through */ }
140
+ }
141
+ return `\x1b[38;2;${Math.round(c.r)};${Math.round(c.g)};${Math.round(c.b)}m`;
142
+ }
143
+
144
+ /** ANSI background escape for an RGB color in the theme's color mode (truecolor or 256). */
145
+ export function rgbBgAnsi(theme: any, c: Rgb): string {
146
+ const mode = typeof theme?.getColorMode === "function" ? theme.getColorMode() : "truecolor";
147
+ const bgAnsi = (PiTui as any).backgroundAnsi;
148
+ const rgbColor = (PiTui as any).rgbColor;
149
+ if (mode !== "truecolor" && typeof bgAnsi === "function" && typeof rgbColor === "function") {
150
+ try {
151
+ return bgAnsi(rgbColor(Math.round(c.r), Math.round(c.g), Math.round(c.b)), mode);
152
+ } catch { /* fall through */ }
153
+ }
154
+ return `\x1b[48;2;${Math.round(c.r)};${Math.round(c.g)};${Math.round(c.b)}m`;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // Shiki theme from the pi theme's syntax colors, so diff previews highlight
159
+ // code the same way pi highlights Markdown code blocks. The scope mapping
160
+ // mirrors pi's own highlight.js mapping (keyword, type, number, string,
161
+ // comment, function, variable, operator, punctuation).
162
+ // ---------------------------------------------------------------------------
163
+
164
+ export interface ShikiThemeRegistration {
165
+ name: string;
166
+ type: "dark" | "light";
167
+ fg: string;
168
+ bg: string;
169
+ settings: Array<{ scope?: string[]; settings: { foreground?: string; background?: string; fontStyle?: string } }>;
170
+ }
171
+
172
+ const SHIKI_SCOPES: Array<[token: string, scopes: string[], fontStyle?: string]> = [
173
+ ["syntaxComment", ["comment", "punctuation.definition.comment", "string.comment"]],
174
+ ["syntaxKeyword", [
175
+ "keyword", "storage", "storage.type", "storage.modifier", "keyword.control",
176
+ "variable.language", "entity.name.tag", "support.type.property-name.json",
177
+ ]],
178
+ ["syntaxOperator", ["keyword.operator", "keyword.operator.assignment", "keyword.operator.arithmetic", "keyword.operator.logical", "keyword.operator.comparison"]],
179
+ ["syntaxPunctuation", ["punctuation", "meta.brace", "meta.delimiter", "punctuation.definition.tag", "punctuation.separator", "punctuation.terminator"]],
180
+ ["syntaxString", ["string", "string.quoted", "string.template", "string.regexp"]],
181
+ ["syntaxNumber", ["constant.numeric", "constant.language", "constant.character", "constant.other", "support.constant"]],
182
+ ["syntaxFunction", ["entity.name.function", "support.function", "meta.function-call.generic", "variable.function"]],
183
+ ["syntaxType", [
184
+ "entity.name.type", "entity.name.class", "entity.name.namespace", "entity.other.inherited-class",
185
+ "support.type", "support.class", "storage.type.primitive", "storage.type.built-in",
186
+ ]],
187
+ ["syntaxVariable", ["variable.parameter", "entity.other.attribute-name", "meta.object-literal.key"]],
188
+ ["mdHeading", ["markup.heading", "entity.name.section"], "bold"],
189
+ ["mdLink", ["markup.underline.link", "string.other.link"]],
190
+ ["mdCode", ["markup.inline.raw", "markup.inline.raw.string", "markup.fenced_code", "markup.raw"]],
191
+ ["mdListBullet", ["punctuation.definition.list"]],
192
+ ["toolDiffAdded", ["markup.inserted"]],
193
+ ["toolDiffRemoved", ["markup.deleted"]],
194
+ ];
195
+
196
+ let shikiCache: { key: string; theme: ShikiThemeRegistration } | null = null;
197
+
198
+ /** Shiki theme from the active pi theme, or null when concrete colors are unavailable. */
199
+ export function shikiThemeFromPiTheme(theme: any): ShikiThemeRegistration | null {
200
+ const defaults = terminalDefaultColors(theme);
201
+ const text = themeTokenRgb(theme, "text") ?? defaults?.foreground ?? null;
202
+ const appearance = themeAppearance(theme);
203
+ if (!text || !appearance) return null;
204
+ const background = defaults?.background ?? (appearance === "light" ? { r: 255, g: 255, b: 255 } : { r: 0, g: 0, b: 0 });
205
+
206
+ const entries: Array<[string, string]> = [];
207
+ for (const [token] of SHIKI_SCOPES) {
208
+ const rgb = themeTokenRgb(theme, token);
209
+ if (rgb) entries.push([token, rgbToHex(rgb)]);
210
+ }
211
+ if (entries.length === 0) return null;
212
+ const colors = Object.fromEntries(entries) as Record<string, string>;
213
+ const fg = rgbToHex(text);
214
+ const bg = rgbToHex(background);
215
+ const key = `${appearance}|${fg}|${bg}|${entries.map(([k, v]) => `${k}=${v}`).join(",")}`;
216
+ if (shikiCache?.key === key) return shikiCache.theme;
217
+
218
+ let hash = 2166136261;
219
+ for (let i = 0; i < key.length; i++) {
220
+ hash ^= key.charCodeAt(i);
221
+ hash = Math.imul(hash, 16777619) >>> 0;
222
+ }
223
+ const settings: ShikiThemeRegistration["settings"] = [{ settings: { foreground: fg, background: bg } }];
224
+ for (const [token, scopes, fontStyle] of SHIKI_SCOPES) {
225
+ const color = colors[token];
226
+ if (!color) continue;
227
+ settings.push({ scope: scopes, settings: fontStyle ? { foreground: color, fontStyle } : { foreground: color } });
228
+ }
229
+ settings.push({ scope: ["markup.bold"], settings: { fontStyle: "bold" } });
230
+ settings.push({ scope: ["markup.italic"], settings: { fontStyle: "italic" } });
231
+
232
+ const registration: ShikiThemeRegistration = {
233
+ // Shiki caches themes by name, so the name changes with the colors.
234
+ name: `pi-theme-${hash.toString(16).padStart(8, "0")}`,
235
+ type: appearance,
236
+ fg,
237
+ bg,
238
+ settings,
239
+ };
240
+ shikiCache = { key, theme: registration };
241
+ return registration;
242
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.90",
3
+ "version": "1.0.92",
4
4
  "description": "Claude Code-style grouped tool rows for pi with Ctrl+O/Ctrl+Shift+O previews, math rendering, and consistent built-in, MCP, and custom tool rendering",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -32,6 +32,8 @@
32
32
  "extensions/spinner.ts",
33
33
  "extensions/settings-path.ts",
34
34
  "extensions/bash-command.ts",
35
+ "extensions/theme-colors.ts",
36
+ "extensions/terminal-theme.ts",
35
37
  "config/config.example.json",
36
38
  "README.md",
37
39
  "CHANGELOG.md"