pi-claude-code-ui 1.0.90 → 1.0.91

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,21 @@
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.91 — 2026-10-24
7
+
8
+ ### Added
9
+
10
+ - **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.
11
+
12
+ ### Fixed
13
+
14
+ - **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.
15
+ - **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.
16
+ - **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.
17
+ - **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.
18
+ - **`diffColors.shikiTheme` is kept on theme switches** - a light/dark switch overwrote it with `github-dark` / `github-light`.
19
+ - **Transparent tool backgrounds on pi 1.4** - pi 1.4 renamed the theme's background table, so `toolBackground` overrides were silently skipped.
20
+
6
21
  ## 1.0.90 — 2026-10-19
7
22
 
8
23
  ### Fixed
package/README.md CHANGED
@@ -28,9 +28,9 @@ 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** — `/cc-theme terminal on` makes pi's theme follow the terminal's own palette (Ghostty, Rex, kitty, WezTerm, …), including switches between two dark or two light themes
32
+ - **Theme-adaptive palette** — borders, branch connectors, 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
+ - **Transparent edit/write diffs** tinted with the theme's own diff colors
34
34
  - **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
35
  - **Extra detail toggle** with `Ctrl+Shift+O`, increasing expanded preview caps without making the default view heavy
36
36
  - **Global border patch** for all tool rows, including unknown/custom tools
@@ -59,35 +59,53 @@ Set in `.pi/settings.json` or the active Pi agent settings file (`$PI_CODING_AGE
59
59
  "diffCollapsedLines": 24,
60
60
  "diffLayout": "auto",
61
61
  "themeAdaptive": true,
62
- "diffTheme": "github-dark"
62
+ "terminalTheme": false
63
63
  }
64
64
  ```
65
65
 
66
+ Leave `diffTheme` unset to highlight diffs with the theme's colors; set it (for example `"github-dark"`) to force a fixed diff palette.
67
+
66
68
  ### Theme integration
67
69
 
68
70
  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
71
 
70
72
  | Element | Derived from |
71
73
  |---------|--------------|
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
+ | Branch connectors (`├`, `└`, `│`) | terminal background blended toward its text color to a 2:1 contrast (rgb 72 on a near-black terminal); `/cc-tools branch theme` uses `dim` → `muted` → `borderMuted` instead, `/cc-tools branch <0-255>` an exact gray |
75
+ | User box, tool rules, code fences, thinking rule | the same blend at a 5.1:1 contrast (rgb ~136 on a near-black terminal) |
74
76
  | "✻ Turn took Ns" line (final message only, with session total + turn count) | `muted` |
75
77
  | Expanded thinking-block text and `∴` marker | `muted` |
76
78
  | Diff add/remove accents | `toolDiffAdded` / `toolDiffRemoved` |
77
- | Diff background tints | mixed against `toolSuccessBg` base |
79
+ | Diff code highlighting | the theme's `syntax*` colors, the same ones pi uses for Markdown code blocks |
80
+ | Diff background tints | the terminal background tinted toward `toolDiffAdded` / `toolDiffRemoved` |
81
+ | Light or dark variants | pi's own decision (`theme.appearance`) |
78
82
  | Spinner verb text (`Working…`) | `borderAccent` (fallback: `accent`) |
79
83
  | Spinner status text | `muted` |
80
84
 
85
+ These need pi 1.4 or newer, which exposes the theme's concrete colors. On older pi versions branch connectors stay rgb 72, diffs use `github-dark` / `github-light`, and diff tints are mixed against `toolSuccessBg`.
86
+
81
87
  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
88
 
83
89
  Set `themeAdaptive: false` to keep the original fixed Claude-style palette regardless of the active pi theme.
84
90
 
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.
91
+ On `/resume`, `/new`, or `/fork`, tool chrome is rebound from the **current** pi theme, so it also follows themes set by other extensions.
92
+
93
+ ### Terminal colors (Ghostty, Rex, …)
94
+
95
+ `/cc-theme terminal on` 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` restores it.
96
+
97
+ 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.
98
+
99
+ ```text
100
+ /cc-theme terminal on # follow the terminal's colors
101
+ /cc-theme terminal off # go back to the previous theme
102
+ /cc-theme terminal status # show the current state
103
+ ```
86
104
 
87
105
  #### Toggle at runtime with `/cc-theme`
88
106
 
89
107
  ```text
90
- /cc-theme # show current setting + theme name
108
+ /cc-theme # show current setting + theme name (and whether terminal colors are on)
91
109
  /cc-theme status # show current setting + color preview (incl. spinner)
92
110
  /cc-theme on # follow pi theme
93
111
  /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": false,
21
21
  "diffColors": {}
22
22
  }
@@ -71,6 +71,25 @@ 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";
@@ -137,9 +156,18 @@ interface SettingsFile {
137
156
  * defaults so users keep full control.
138
157
  */
139
158
  themeAdaptive?: boolean;
159
+ /**
160
+ * When true, pi uses its built-in `system` theme, which takes every color
161
+ * from the terminal's own palette (Ghostty, Rex, ...), and re-reads that
162
+ * palette when the terminal regains focus so same-kind theme switches
163
+ * (dark to dark) are picked up too. Toggle with `/cc-theme terminal`.
164
+ */
165
+ terminalTheme?: boolean;
166
+ /** Pi theme that was active before `terminalTheme` was turned on; restored when it is turned off. */
167
+ terminalThemePrevious?: string;
140
168
  /**
141
169
  * 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
170
+ * to "borderAccent". Useful when the active theme's accent is overloaded for
143
171
  * borders, headings, or bash mode and the verb should pop differently.
144
172
  * Valid keys are any of the pi theme `ThemeColor` names (e.g. accent,
145
173
  * borderAccent, success, warning, mdHeading, thinkingMedium, bashMode).
@@ -157,9 +185,13 @@ interface SettingsFile {
157
185
  * renders expanded, like stock pi.
158
186
  */
159
187
  thinkingMode?: "live" | "full";
160
- /** Gray level 0–255 for ├ └ │ when branch color mode is `fixed`. */
188
+ /** Exact gray level 0–255 for ├ └ │ in `fixed` mode. Unset: a fixed-contrast blend of the terminal background and text. */
161
189
  toolBranchRgbGray?: number;
162
- /** `fixed` (default): rgb gray 72, theme-independent. `theme`: dim → muted → borderMuted. */
190
+ /**
191
+ * `fixed` (default): the terminal background blended toward its text color
192
+ * to a fixed 2:1 contrast (rgb 72 on a #161616 terminal), or exactly
193
+ * `toolBranchRgbGray` when set. `theme`: the theme's dim → muted → borderMuted color.
194
+ */
163
195
  toolBranchColorMode?: "theme" | "fixed";
164
196
  }
165
197
 
@@ -233,7 +265,10 @@ function syncToolBackgroundMode(): void {
233
265
 
234
266
  function setThemeBg(theme: unknown, key: string, value: string): void {
235
267
  const themeAny = theme as any;
236
- if (themeAny.bgColors instanceof Map) {
268
+ // pi >= 1.4 keeps background escapes in `bgAnsi`; older versions in `bgColors`.
269
+ if (themeAny.bgAnsi instanceof Map) {
270
+ themeAny.bgAnsi.set(key, value);
271
+ } else if (themeAny.bgColors instanceof Map) {
237
272
  themeAny.bgColors.set(key, value);
238
273
  } else if (themeAny.bgColors && typeof themeAny.bgColors === "object") {
239
274
  themeAny.bgColors[key] = value;
@@ -4203,8 +4238,11 @@ function currentThemePaletteFingerprint(): string | undefined {
4203
4238
 
4204
4239
  /** Resolved-color fingerprint so palette re-derives when the active theme file changes under the same name/object. */
4205
4240
  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");
4241
+ const keys = ["success", "error", "borderMuted", "accent", "muted", "toolDiffAdded", "toolDiffRemoved", "syntaxKeyword", "syntaxString"] as const;
4242
+ // Tokens set to "" follow the terminal's default colors, which can change under the same theme.
4243
+ const defaults = terminalDefaultColors(theme);
4244
+ const terminal = defaults ? `${rgbToHex(defaults.background)}${rgbToHex(defaults.foreground)}` : "";
4245
+ return [...keys.map((k) => safeFgAnsi(theme, k) ?? ""), themeAppearance(theme) ?? "", terminal].join("\u001f");
4208
4246
  }
4209
4247
 
4210
4248
  function invalidateThemePaletteCache(): void {
@@ -4218,7 +4256,67 @@ function themeAdaptiveEnabled(): boolean {
4218
4256
  return settings.themeAdaptive !== false;
4219
4257
  }
4220
4258
 
4221
- let DIFF_THEME: BundledTheme = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
4259
+ // ---------------------------------------------------------------------------
4260
+ // terminalTheme: pi follows the terminal's own palette (Ghostty, Rex, ...).
4261
+ // ---------------------------------------------------------------------------
4262
+
4263
+ function terminalThemeEnabled(): boolean {
4264
+ return readSettings().terminalTheme === true;
4265
+ }
4266
+
4267
+ /** Pi's saved `theme` setting (may be a "light/dark" pair), from pi's own settings file. */
4268
+ function readPiThemeSetting(): string | undefined {
4269
+ const agentDir = process.env.PI_CODING_AGENT_DIR || `${process.env.HOME ?? ""}/.pi/agent`;
4270
+ try {
4271
+ const raw = JSON.parse(readFileSync(`${agentDir}/settings.json`, "utf8"));
4272
+ return typeof raw?.theme === "string" && raw.theme ? raw.theme : undefined;
4273
+ } catch {
4274
+ return undefined;
4275
+ }
4276
+ }
4277
+
4278
+ /** Switch pi to its terminal-palette theme and keep it in sync. Returns false if unavailable. */
4279
+ function applyTerminalTheme(ctx: any, options: { announce: boolean }): boolean {
4280
+ if (!ctx?.hasUI) return false;
4281
+ if (!systemThemeAvailable(ctx.ui)) {
4282
+ ctx.ui.notify("Terminal colors need pi's built-in `system` theme (pi 1.4 or newer).", "error");
4283
+ return false;
4284
+ }
4285
+ const current = activeThemeName(ctx.ui);
4286
+ if (current !== SYSTEM_THEME_NAME) {
4287
+ // Remember what to go back to; a "light/dark" pair is kept as the pair.
4288
+ const previous = readPiThemeSetting() ?? current;
4289
+ if (previous && previous !== SYSTEM_THEME_NAME) writeSettingsKey("terminalThemePrevious", previous);
4290
+ const result = ctx.ui.setTheme(SYSTEM_THEME_NAME);
4291
+ if (!result?.success) {
4292
+ ctx.ui.notify(`Could not switch to the terminal theme: ${result?.error ?? "unknown error"}`, "error");
4293
+ return false;
4294
+ }
4295
+ if (options.announce) {
4296
+ ctx.ui.notify(`Theme follows the terminal's colors (was "${previous ?? current}"). /cc-theme terminal off to switch back.`, "info");
4297
+ }
4298
+ }
4299
+ startFocusResync(ctx.ui, terminalThemeEnabled);
4300
+ return true;
4301
+ }
4302
+
4303
+ /** Turn terminalTheme off and restore the theme that was active before it. */
4304
+ function restorePreviousTheme(ctx: any): string | undefined {
4305
+ const previous = readSettings().terminalThemePrevious;
4306
+ writeSettingsKey("terminalThemePrevious", undefined);
4307
+ if (!ctx?.hasUI || !previous || activeThemeName(ctx.ui) !== SYSTEM_THEME_NAME) return undefined;
4308
+ let name = previous;
4309
+ if (previous.includes("/")) {
4310
+ // ctx.ui.setTheme takes one theme name; pick the pair member for the current appearance.
4311
+ const [light, dark] = previous.split("/");
4312
+ name = themeAppearance(ctx.ui.theme) === "light" ? light : dark;
4313
+ }
4314
+ const result = ctx.ui.setTheme(name);
4315
+ return result?.success ? name : undefined;
4316
+ }
4317
+
4318
+ /** Shiki theme for diff previews: a bundled theme name, or one generated from the pi theme. */
4319
+ let DIFF_THEME: BundledTheme | ShikiThemeRegistration = (process.env.DIFF_THEME as BundledTheme | undefined) ?? "github-dark";
4222
4320
  /** True when the active pi theme has a light panel background (edit/write diff chrome). */
4223
4321
  let _diffOnLightBg = false;
4224
4322
  let codeToAnsiLoader: Promise<any> | null = null;
@@ -4260,8 +4358,40 @@ let FG_DEL = "\x1b[38;2;200;100;100m";
4260
4358
  let FG_DIM = "\x1b[38;2;80;80;80m";
4261
4359
  let FG_LNUM = "\x1b[38;2;100;100;100m";
4262
4360
  let FG_RULE = "\x1b[38;2;50;50;50m";
4263
- // Tool branch connectors (├ └ │). Default fixed gray 72 — independent of pi theme.
4361
+ // Tool branch connectors (├ └ │). The default ("fixed" mode without an explicit gray) blends the
4362
+ // terminal's background toward its text color until it has the contrast rgb 72 has on a #161616
4363
+ // terminal (2.0:1), so connectors keep that weight on light and tinted themes and take on the
4364
+ // theme's tint. Outline chrome (user box, code fences, thinking rule) does the same at the
4365
+ // contrast of rgb 136 (5.1:1). Gray 72 / +64 is the fallback when pi does not expose concrete
4366
+ // theme colors (pi < 1.4) or themeAdaptive is off.
4264
4367
  const DEFAULT_TOOL_BRANCH_GRAY = 72;
4368
+ const BRANCH_CHROME_CONTRAST = 2.0;
4369
+ const OUTLINE_CHROME_CONTRAST = 5.1;
4370
+
4371
+ // Branch connectors resolve their color on every rendered row, so keep the solved blends.
4372
+ const _chromeBlendCache = new Map<string, string>();
4373
+
4374
+ /** Terminal background blended toward its text color to `contrast`, or null without concrete colors. */
4375
+ function themeChromeBlend(theme: any, contrast: number): string | null {
4376
+ if (!theme || !themeAdaptiveEnabled()) return null;
4377
+ const defaults = terminalDefaultColors(theme);
4378
+ if (!defaults) return null;
4379
+ const { background: bg, foreground: fg } = defaults;
4380
+ const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "";
4381
+ const key = `${contrast}|${bg.r},${bg.g},${bg.b}|${fg.r},${fg.g},${fg.b}|${mode}`;
4382
+ let ansi = _chromeBlendCache.get(key);
4383
+ if (!ansi) {
4384
+ if (_chromeBlendCache.size > 32) _chromeBlendCache.clear();
4385
+ ansi = rgbFgAnsi(theme, blendForContrast(bg, fg, contrast));
4386
+ _chromeBlendCache.set(key, ansi);
4387
+ }
4388
+ return ansi;
4389
+ }
4390
+
4391
+ function hasExplicitToolBranchGray(): boolean {
4392
+ const raw = readSettings().toolBranchRgbGray;
4393
+ return typeof raw === "number" && Number.isFinite(raw);
4394
+ }
4265
4395
 
4266
4396
  function toolBranchRgbAnsi(gray: number): string {
4267
4397
  const g = Math.max(0, Math.min(255, Math.round(gray)));
@@ -4275,11 +4405,17 @@ function ansiRgbBrightenedBy(ansi: string, delta: number): string | null {
4275
4405
  return `\x1b[38;2;${bump(rgb.r)};${bump(rgb.g)};${bump(rgb.b)}m`;
4276
4406
  }
4277
4407
 
4278
- /** Outline chrome always brighter than branch; never falls back to identical branch ANSI. */
4408
+ /** Outline chrome always stands out more than the branch; never falls back to identical branch ANSI. */
4279
4409
  function outlineChromeAnsiFromBranch(theme?: any): string {
4280
4410
  const t = theme ?? _toolBranchThemeHint;
4411
+ if (toolBranchColorModeFixed() && !hasExplicitToolBranchGray()) {
4412
+ const blend = themeChromeBlend(t, OUTLINE_CHROME_CONTRAST);
4413
+ if (blend) return blend;
4414
+ }
4281
4415
  const branch = currentToolBranchAnsi(t);
4282
- const fromBranch = ansiRgbBrightenedBy(branch, OUTLINE_CHROME_BRIGHTEN);
4416
+ // Toward the text color: brighter on dark themes, darker on light ones.
4417
+ const towardText = t && isLightThemeBackground(t) ? -OUTLINE_CHROME_BRIGHTEN : OUTLINE_CHROME_BRIGHTEN;
4418
+ const fromBranch = ansiRgbBrightenedBy(branch, towardText);
4283
4419
  if (fromBranch) return fromBranch;
4284
4420
  let gray = DEFAULT_TOOL_BRANCH_GRAY;
4285
4421
  if (toolBranchColorModeFixed()) {
@@ -4302,8 +4438,8 @@ function toolBranchColorModeFixed(): boolean {
4302
4438
  }
4303
4439
 
4304
4440
  function toolBranchRenderCacheKey(): string {
4305
- if (toolBranchColorModeFixed()) return `fixed:${getConfiguredToolBranchGray()}`;
4306
- return `theme:${stripAnsi(TOOL_RULE)}`;
4441
+ // The escape itself, not stripAnsi(TOOL_RULE) (which is always ""), so a new color is a new key.
4442
+ return `${toolBranchColorModeFixed() ? "fixed" : "theme"}:${TOOL_RULE}`;
4307
4443
  }
4308
4444
 
4309
4445
  let _toolBranchVisualEpoch = 0;
@@ -4343,6 +4479,10 @@ let _toolBranchThemeHint: any;
4343
4479
  function currentToolBranchAnsi(theme?: any): string {
4344
4480
  const t = theme ?? _toolBranchThemeHint;
4345
4481
  if (toolBranchColorModeFixed()) {
4482
+ if (!hasExplicitToolBranchGray()) {
4483
+ const blend = themeChromeBlend(t, BRANCH_CHROME_CONTRAST);
4484
+ if (blend) return blend;
4485
+ }
4346
4486
  return toolBranchRgbAnsi(getConfiguredToolBranchGray());
4347
4487
  }
4348
4488
  const chrome = t ? resolveThemeChromeFg(t) : null;
@@ -4492,6 +4632,10 @@ const FALLBACK_BASE_BG_DARK = { r: 32, g: 35, b: 42 };
4492
4632
  const FALLBACK_BASE_BG_LIGHT = { r: 232, g: 233, b: 236 };
4493
4633
 
4494
4634
  function isLightThemeBackground(theme: any): boolean {
4635
+ // pi >= 1.4 decides light/dark itself (from the terminal's colors for the system theme);
4636
+ // use the same answer so diffs, chrome, and pi's own UI never disagree.
4637
+ const appearance = themeAppearance(theme);
4638
+ if (appearance) return appearance === "light";
4495
4639
  const panel =
4496
4640
  themeBgRgb(theme, "toolSuccessBg") ||
4497
4641
  themeBgRgb(theme, "userMessageBg") ||
@@ -4511,10 +4655,18 @@ function isLightThemeBackground(theme: any): boolean {
4511
4655
  function syncDiffShikiTheme(theme: any): void {
4512
4656
  if (process.env.DIFF_THEME) return;
4513
4657
  const config = loadDiffConfig();
4514
- if (config.diffTheme) return;
4658
+ if (config.diffTheme || config.diffColors?.shikiTheme) return;
4515
4659
  _diffOnLightBg = isLightThemeBackground(theme);
4516
- DIFF_THEME = (_diffOnLightBg ? "github-light" : "github-dark") as BundledTheme;
4517
- clearHighlightCache();
4660
+ // Highlight diffs with the theme's own syntax colors (the ones pi uses for Markdown code
4661
+ // blocks). GitHub's palette is the fallback for pi versions without concrete theme colors.
4662
+ const fromTheme = themeAdaptiveEnabled() ? shikiThemeFromPiTheme(theme) : null;
4663
+ const next = fromTheme ?? ((_diffOnLightBg ? "github-light" : "github-dark") as BundledTheme);
4664
+ if (diffThemeName(next) !== diffThemeName(DIFF_THEME)) clearHighlightCache();
4665
+ DIFF_THEME = next;
4666
+ }
4667
+
4668
+ function diffThemeName(theme: BundledTheme | ShikiThemeRegistration): string {
4669
+ return typeof theme === "string" ? theme : theme.name;
4518
4670
  }
4519
4671
  const UNIVERSAL_DIFF_ADD_FG = { r: 110, g: 210, b: 130 };
4520
4672
  const UNIVERSAL_DIFF_DEL_FG = { r: 225, g: 110, b: 110 };
@@ -4535,17 +4687,65 @@ function rgbToBgAnsi(c: { r: number; g: number; b: number }): string {
4535
4687
  return `\x1b[48;2;${Math.round(c.r)};${Math.round(c.g)};${Math.round(c.b)}m`;
4536
4688
  }
4537
4689
 
4690
+ // Diff row tints: how far each background moves from the surface toward the theme's diff color.
4691
+ // Light themes need less, because the same share of a mid-tone color reads stronger on white.
4692
+ const DIFF_TINT = {
4693
+ dark: { line: { add: 0.2, del: 0.17 }, word: { add: 0.36, del: 0.32 }, gutter: { add: 0.12, del: 0.1 } },
4694
+ light: { line: { add: 0.16, del: 0.13 }, word: { add: 0.3, del: 0.26 }, gutter: { add: 0.1, del: 0.08 } },
4695
+ } as const;
4696
+
4697
+ /** The color a diff row is drawn on: the terminal background, or pi's tool panel in `default` tool style. */
4698
+ function diffSurfaceRgb(theme: any): Rgb | null {
4699
+ if (toolBackgroundMode === "default") {
4700
+ const panel = themeTokenRgb(theme, "toolSuccessBg");
4701
+ if (panel) return panel;
4702
+ }
4703
+ return terminalDefaultColors(theme)?.background ?? null;
4704
+ }
4705
+
4706
+ /**
4707
+ * Diff colors straight from the theme: text in `toolDiffAdded` / `toolDiffRemoved`, and
4708
+ * backgrounds that tint the row's real surface toward those same colors. Returns false on pi
4709
+ * versions without concrete theme colors.
4710
+ */
4711
+ function deriveDiffPaletteFromThemeColors(theme: any): boolean {
4712
+ const add = themeTokenRgb(theme, "toolDiffAdded");
4713
+ const del = themeTokenRgb(theme, "toolDiffRemoved");
4714
+ const surface = diffSurfaceRgb(theme);
4715
+ if (!add || !del || !surface) return false;
4716
+ const tint = DIFF_TINT[isLightThemeBackground(theme) ? "light" : "dark"];
4717
+ const bg = (color: Rgb, amount: number) => rgbBgAnsi(theme, mixThemeRgb(surface, color, amount));
4718
+ FG_ADD = rgbFgAnsi(theme, add);
4719
+ FG_DEL = rgbFgAnsi(theme, del);
4720
+ BG_ADD = bg(add, tint.line.add);
4721
+ BG_DEL = bg(del, tint.line.del);
4722
+ BG_ADD_W = bg(add, tint.word.add);
4723
+ BG_DEL_W = bg(del, tint.word.del);
4724
+ BG_GUTTER_ADD = bg(add, tint.gutter.add);
4725
+ BG_GUTTER_DEL = bg(del, tint.gutter.del);
4726
+ return true;
4727
+ }
4728
+
4538
4729
  function autoDeriveBgFromTheme(theme: any): void {
4539
4730
  // Diff palette derivation.
4540
4731
  //
4541
4732
  // `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.
4733
+ // fg accents. On pi >= 1.4 the backgrounds tint the row's real surface toward
4734
+ // those colors (deriveDiffPaletteFromThemeColors). Older pi versions only
4735
+ // expose ANSI escapes, so the base is taken from `toolSuccessBg` and mixed
4736
+ // with fixed tint targets. Falls back to the universal dark palette when the
4737
+ // theme is unavailable or themeAdaptive=false.
4546
4738
  const useTheme = themeAdaptiveEnabled() && theme;
4547
4739
  const onLight = useTheme && isLightThemeBackground(theme);
4548
4740
  _diffOnLightBg = !!onLight;
4741
+ if (useTheme && deriveDiffPaletteFromThemeColors(theme)) {
4742
+ BG_EMPTY = TRANSPARENT_BG;
4743
+ BG_BASE = TRANSPARENT_BG;
4744
+ D_RST = TRANSPARENT_RESET;
4745
+ DIVIDER = `${FG_RULE}│${D_RST}`;
4746
+ DEFAULT_DIFF_COLORS = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
4747
+ return;
4748
+ }
4549
4749
  const addFgRgb = (useTheme && themeFgRgb(theme, "toolDiffAdded")) || UNIVERSAL_DIFF_ADD_FG;
4550
4750
  const delFgRgb = (useTheme && themeFgRgb(theme, "toolDiffRemoved")) || UNIVERSAL_DIFF_DEL_FG;
4551
4751
  const base =
@@ -4634,7 +4834,12 @@ function applyThemePaletteIfNeeded(theme: any): void {
4634
4834
  }
4635
4835
  const paletteChanged =
4636
4836
  _themePaletteCacheName !== themeName || _themePaletteCacheFingerprint !== fingerprint;
4637
- if (paletteChanged) bumpToolBranchVisualEpoch();
4837
+ if (paletteChanged) {
4838
+ bumpToolBranchVisualEpoch();
4839
+ // A new theme object (e.g. the system theme rebuilt after a terminal theme change)
4840
+ // comes with pi's own tool backgrounds; re-apply the configured tool style to it.
4841
+ applyToolBackgroundMode(theme);
4842
+ }
4638
4843
  _themePaletteCacheTheme = theme;
4639
4844
  _themePaletteCacheName = themeName;
4640
4845
  _themePaletteCacheFingerprint = fingerprint;
@@ -5103,7 +5308,7 @@ function lang(filePath: string): BundledLanguage | undefined {
5103
5308
  return EXT_LANG[extname(filePath).slice(1).toLowerCase()];
5104
5309
  }
5105
5310
 
5106
- async function codeToAnsiLazy(code: string, language: BundledLanguage, theme: BundledTheme): Promise<string> {
5311
+ async function codeToAnsiLazy(code: string, language: BundledLanguage, theme: BundledTheme | ShikiThemeRegistration): Promise<string> {
5107
5312
  if (!codeToAnsiLoader) {
5108
5313
  // Self-healing: a failed import (missing dep, transient error) must not leave a
5109
5314
  // permanently-rejected promise that later becomes an unhandled rejection. Reset
@@ -5140,11 +5345,15 @@ function touchCache(key: string, value: string[]): string[] {
5140
5345
  async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
5141
5346
  if (!code) return [""];
5142
5347
  if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
5143
- const key = `${DIFF_THEME}\0${language}\0${code}`;
5348
+ const diffTheme = DIFF_THEME;
5349
+ const key = `${diffThemeName(diffTheme)}\0${language}\0${code}`;
5144
5350
  const hit = hlCache.get(key);
5145
5351
  if (hit) return touchCache(key, hit);
5146
5352
  try {
5147
- const ansi = normalizeShikiContrast(await codeToAnsiLazy(code, language, DIFF_THEME));
5353
+ const raw = await codeToAnsiLazy(code, language, diffTheme as any);
5354
+ // Themes generated from pi already meet pi's contrast rules for this background;
5355
+ // only bundled themes need their too-dark / too-light colors muted.
5356
+ const ansi = typeof diffTheme === "string" ? normalizeShikiContrast(raw) : raw;
5148
5357
  const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
5149
5358
  return touchCache(key, out);
5150
5359
  } catch {
@@ -7086,9 +7295,11 @@ export default function (pi: ExtensionAPI) {
7086
7295
  const chromeHint = branchMode === "theme" && theme
7087
7296
  ? (resolveThemeChromeFg(theme) ? " (attenuated on light themes)" : " (fallback gray if theme keys missing)")
7088
7297
  : "";
7089
- const branchLine = branchMode === "fixed"
7090
- ? `Branch color: fixed rgb(${branchGray})`
7091
- : `Branch color: theme${chromeHint}`;
7298
+ const branchLine = branchMode === "theme"
7299
+ ? `Branch color: theme${chromeHint}`
7300
+ : hasExplicitToolBranchGray() || !themeChromeBlend(theme, BRANCH_CHROME_CONTRAST)
7301
+ ? `Branch color: fixed rgb(${branchGray})`
7302
+ : "Branch color: fixed 2:1 contrast on the terminal background (rgb 72 on near-black)";
7092
7303
  ctx.ui.notify([
7093
7304
  `Tool style: ${toolBackgroundMode}`,
7094
7305
  `Tool grouping: ${toolGroupingEnabled() ? "on" : "off"}`,
@@ -7178,7 +7389,7 @@ export default function (pi: ExtensionAPI) {
7178
7389
  writeSettingsKey("toolBranchRgbGray", undefined);
7179
7390
  writeSettingsKey("toolBranchColorMode", undefined);
7180
7391
  if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7181
- if (ctx.hasUI) ctx.ui.notify(`Branch color → fixed rgb(${DEFAULT_TOOL_BRANCH_GRAY}) (default)`, "info");
7392
+ if (ctx.hasUI) ctx.ui.notify("Branch color → default (fixed contrast on the terminal background)", "info");
7182
7393
  return;
7183
7394
  }
7184
7395
  if (arg === "theme") {
@@ -7190,7 +7401,9 @@ export default function (pi: ExtensionAPI) {
7190
7401
  if (arg === "fixed") {
7191
7402
  writeSettingsKey("toolBranchColorMode", "fixed");
7192
7403
  if (ctx.hasUI) refreshAllToolBranchVisuals(ctx);
7193
- if (ctx.hasUI) ctx.ui.notify(`Branch color → fixed rgb(${getConfiguredToolBranchGray()})`, "info");
7404
+ if (ctx.hasUI) ctx.ui.notify(hasExplicitToolBranchGray()
7405
+ ? `Branch color → fixed rgb(${getConfiguredToolBranchGray()})`
7406
+ : "Branch color → fixed contrast on the terminal background", "info");
7194
7407
  return;
7195
7408
  }
7196
7409
  const gray = Number.parseInt(arg, 10);
@@ -7255,11 +7468,66 @@ export default function (pi: ExtensionAPI) {
7255
7468
  },
7256
7469
  });
7257
7470
 
7258
- // /cc-theme command — toggle pi-theme-adaptive coloring at runtime.
7259
- const THEME_MODES = ["on", "off", "toggle", "status"] as const;
7471
+ // /cc-theme command — toggle pi-theme-adaptive coloring at runtime, and
7472
+ // /cc-theme terminal — make pi's theme follow the terminal's palette.
7473
+ const THEME_MODES = ["on", "off", "toggle", "status", "terminal"] as const;
7474
+ const TERMINAL_MODES = ["on", "off", "toggle", "status"] as const;
7475
+ const handleTerminalTheme = (arg: string, ctx: any): void => {
7476
+ const current = terminalThemeEnabled();
7477
+ if (!arg || arg === "status") {
7478
+ if (!ctx.hasUI) return;
7479
+ const name = activeThemeName(ctx.ui) ?? "unknown";
7480
+ const previous = readSettings().terminalThemePrevious;
7481
+ ctx.ui.notify([
7482
+ `Terminal colors: ${current ? "on" : "off"} (pi theme "${name}")`,
7483
+ current && previous ? ` /cc-theme terminal off restores "${previous}"` : "",
7484
+ current && name !== SYSTEM_THEME_NAME ? " pi is not using the system theme right now; /cc-theme terminal on switches it back" : "",
7485
+ ].filter(Boolean).join("\n"), "info");
7486
+ return;
7487
+ }
7488
+ let next: boolean;
7489
+ if (arg === "on") next = true;
7490
+ else if (arg === "off") next = false;
7491
+ else if (arg === "toggle") next = !current;
7492
+ else {
7493
+ if (ctx.hasUI) ctx.ui.notify(`Usage: /cc-theme terminal ${TERMINAL_MODES.join("|")}`, "error");
7494
+ return;
7495
+ }
7496
+ if (next) {
7497
+ writeSettingsKey("terminalTheme", true);
7498
+ if (!applyTerminalTheme(ctx, { announce: false })) {
7499
+ writeSettingsKey("terminalTheme", undefined);
7500
+ return;
7501
+ }
7502
+ requestTerminalColorScheme();
7503
+ if (ctx.hasUI) ctx.ui.notify("Terminal colors: on — pi's theme now follows the terminal (Ghostty, Rex, ...)", "info");
7504
+ } else {
7505
+ writeSettingsKey("terminalTheme", undefined);
7506
+ const restored = restorePreviousTheme(ctx);
7507
+ if (ctx.hasUI) {
7508
+ ctx.ui.notify(restored
7509
+ ? `Terminal colors: off — restored theme "${restored}"`
7510
+ : "Terminal colors: off — pick a theme with /settings", "info");
7511
+ }
7512
+ }
7513
+ };
7260
7514
  pi.registerCommand("cc-theme", {
7261
- description: "Toggle whether tool borders / branch rules / diff colors follow the active pi theme",
7515
+ description: "Follow the terminal's colors (terminal on|off), or toggle whether tool chrome / diff colors follow the pi theme",
7262
7516
  getArgumentCompletions(prefix) {
7517
+ if (prefix.startsWith("terminal ")) {
7518
+ const second = prefix.slice("terminal ".length);
7519
+ return TERMINAL_MODES
7520
+ .filter((m) => m.startsWith(second))
7521
+ .map((m) => ({
7522
+ value: `terminal ${m}`,
7523
+ label: m,
7524
+ description:
7525
+ m === "on" ? "Use pi's system theme, built from the terminal's palette, and re-read it on focus"
7526
+ : m === "off" ? "Go back to the theme you had before"
7527
+ : m === "toggle" ? "Flip between on and off"
7528
+ : "Show whether pi follows the terminal's colors",
7529
+ }));
7530
+ }
7263
7531
  return THEME_MODES
7264
7532
  .filter((m) => m.startsWith(prefix))
7265
7533
  .map((m) => ({
@@ -7269,11 +7537,16 @@ export default function (pi: ExtensionAPI) {
7269
7537
  m === "on" ? "Derive borders, branch rules, dim text and diff tints from the active pi theme (default)"
7270
7538
  : m === "off" ? "Keep the fixed Claude-style palette regardless of theme"
7271
7539
  : m === "toggle" ? "Flip between on and off"
7540
+ : m === "terminal" ? "Make pi's theme follow the terminal's colors (Ghostty, Rex, ...)"
7272
7541
  : "Show the current setting and a preview of the derived colors",
7273
7542
  }));
7274
7543
  },
7275
7544
  async handler(args, ctx) {
7276
7545
  const raw = args.trim().toLowerCase();
7546
+ if (raw === "terminal" || raw.startsWith("terminal ")) {
7547
+ handleTerminalTheme(raw.slice("terminal".length).trim(), ctx);
7548
+ return;
7549
+ }
7277
7550
  const current = themeAdaptiveEnabled();
7278
7551
 
7279
7552
  if (!raw || raw === "status") {
@@ -7281,6 +7554,7 @@ export default function (pi: ExtensionAPI) {
7281
7554
  const theme = ctx.ui.theme as any;
7282
7555
  const themeName = theme?.name ?? "unknown";
7283
7556
  const state = current ? "on" : "off";
7557
+ const terminalLine = `terminal colors: ${terminalThemeEnabled() ? "on" : "off"} (/cc-theme terminal on|off)`;
7284
7558
  if (raw === "status" && current) {
7285
7559
  const settings = readSettings();
7286
7560
  const verbKey = settings.spinnerVerbColor || "borderAccent";
@@ -7298,9 +7572,9 @@ export default function (pi: ExtensionAPI) {
7298
7572
  `spinner verb: ${verbAnsi ? `${verbAnsi}Cooking…\x1b[39m` : "(unchanged)"} (key: ${verbKey})`,
7299
7573
  `spinner stat: ${statusAnsi ? `${statusAnsi}(thinking · ↓ 10 tokens · 2s)\x1b[39m` : "(unchanged)"} (key: ${statusKey})`,
7300
7574
  ].join("\n ");
7301
- ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")\n ${preview}`, "info");
7575
+ ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")\n ${terminalLine}\n ${preview}`, "info");
7302
7576
  } else {
7303
- ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")`, "info");
7577
+ ctx.ui.notify(`Theme adaptive: ${state} (theme "${themeName}")\n ${terminalLine}`, "info");
7304
7578
  }
7305
7579
  return;
7306
7580
  }
@@ -7434,6 +7708,8 @@ export default function (pi: ExtensionAPI) {
7434
7708
  clearRtkRewriteState();
7435
7709
  if (!ctx.hasUI) return;
7436
7710
  patchUiNotifications(ctx.ui);
7711
+ // terminalTheme: switch to (and stay on) the theme built from the terminal's palette.
7712
+ if (terminalThemeEnabled()) applyTerminalTheme(ctx, { announce: true });
7437
7713
  // Session switch (/resume, /new) can leave tool chrome from the previous
7438
7714
  // theme; rebind from ctx.ui.theme (other extensions may setTheme in the
7439
7715
  // 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.91",
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"