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 +22 -0
- package/README.md +28 -9
- package/config/config.example.json +1 -1
- package/extensions/index.ts +324 -162
- package/extensions/terminal-theme.ts +132 -0
- package/extensions/theme-colors.ts +242 -0
- package/package.json +3 -1
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
|
-
- **
|
|
32
|
-
- **
|
|
33
|
-
- **
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
80
|
-
//
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4264
|
-
|
|
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
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
if (
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
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
|
-
|
|
4306
|
-
return
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
4433
|
+
/** User box, code fences, thinking/thought: the outline chrome color (stronger than connectors). */
|
|
4354
4434
|
function syncOutlineChromeFromBranch(theme?: any): void {
|
|
4355
|
-
const outline =
|
|
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
|
|
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(
|
|
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
|
-
|
|
4517
|
-
|
|
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.
|
|
4543
|
-
//
|
|
4544
|
-
//
|
|
4545
|
-
//
|
|
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(
|
|
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)
|
|
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
|
|
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
|
|
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", "
|
|
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
|
-
|
|
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: "
|
|
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
|
-
`
|
|
7294
|
-
` (
|
|
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.
|
|
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"
|