From fd2895799e7303f5f4d39fa56135185121b9567c Mon Sep 17 00:00:00 2001 From: GaganSD Date: Wed, 28 Oct 2026 02:11:41 +0530 Subject: [PATCH 01/14] pi-slate: add Catppuccin Mocha pink theme --- pi-slate/CHANGELOG.md | 4 + pi-slate/README.md | 3 +- pi-slate/extensions/pi-slate/catppuccin.ts | 4 +- pi-slate/extensions/pi-slate/layout.ts | 4 +- pi-slate/tests/themes.test.ts | 3 +- pi-slate/themes/catppuccin-mocha-pink.json | 107 +++++++++++++++++++++ 6 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 pi-slate/themes/catppuccin-mocha-pink.json diff --git a/pi-slate/CHANGELOG.md b/pi-slate/CHANGELOG.md index 9c997219..410f6352 100644 --- a/pi-slate/CHANGELOG.md +++ b/pi-slate/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- `catppuccin-mocha-pink` theme, selectable with `/slate theme pink` or `/slate style pink`. + ## 0.1.18 Pi 1.4.0 compatibility, session titles, and an opt-in effort-colored composer frame. diff --git a/pi-slate/README.md b/pi-slate/README.md index 7724a8ee..7a9ba95a 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -74,6 +74,7 @@ Slate supports the following themes and can be changed with `/slate theme | `black-metal` | ⬛ | | `quiet` | ⬜ | | `mauve` | 🟪 | +| `pink` | 🩷 | | `sapphire` | 🟦 | | `peach` | 🟧 | | `teal` | 🟩 | @@ -125,7 +126,7 @@ These work when `editor` is selected. No terminal configuration. | Message length | `/slate message-length [default\|all\|]` | How many chat messages stay on screen. `default` is 100. | | Density | `/slate density [comfortable\|compact]` | Comfortable shows a › prompt in the composer; compact is tighter. | | Composer metadata | `/slate footer [standard\|minimal]` | Legacy command alias: standard shows model and thinking on the composer; minimal hides them. Not footer-slot ownership. | -| Theme | `/slate theme [default\|tokyo-night\|black-metal\|quiet\|mauve\|sapphire\|peach\|teal]` | Default restores Pi's system theme. Tokyo Night and Black Metal are explicit; other styles select Catppuccin; `/slate style` retains Catppuccin aliases. | +| Theme | `/slate theme [default\|tokyo-night\|black-metal\|quiet\|mauve\|pink\|sapphire\|peach\|teal]` | Default restores Pi's system theme. Tokyo Night and Black Metal are explicit; other styles select Catppuccin; `/slate style` retains Catppuccin aliases. | | Bugs | `/slate bug [file\|open]` | Copy a bug report, or open the npm package page. | ## Independent Surfaces diff --git a/pi-slate/extensions/pi-slate/catppuccin.ts b/pi-slate/extensions/pi-slate/catppuccin.ts index 2555d3e0..edd02497 100644 --- a/pi-slate/extensions/pi-slate/catppuccin.ts +++ b/pi-slate/extensions/pi-slate/catppuccin.ts @@ -1,7 +1,7 @@ import { effortPalette } from "./theme-palette.ts"; export const FLAVORS = ["mocha"] as const; -export const STYLES = ["default", "quiet", "mauve", "sapphire", "peach", "teal"] as const; +export const STYLES = ["default", "quiet", "mauve", "pink", "sapphire", "peach", "teal"] as const; export type Flavor = (typeof FLAVORS)[number]; export type Style = (typeof STYLES)[number]; @@ -14,6 +14,7 @@ export const STYLE_LABELS: Record = { default: "Default", quiet: "Quiet", mauve: "Mauve", + pink: "Pink", sapphire: "Sapphire", peach: "Peach", teal: "Teal", @@ -46,6 +47,7 @@ const STYLE_ROLES: Record|full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme [default|tokyo-night|black-metal|quiet|mauve|sapphire|peach|teal] | style [default|quiet|mauve|sapphire|peach|teal] | bug [file|open]"; + "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme [default|tokyo-night|black-metal|quiet|mauve|pink|sapphire|peach|teal] | style [default|quiet|mauve|pink|sapphire|peach|teal] | bug [file|open]"; export function withCurrent(label: string, current: boolean): string { return current ? `${label} (current)` : label; @@ -313,7 +313,7 @@ export function withoutCurrent(label: string): string { return label.endsWith(" (current)") ? label.slice(0, -" (current)".length) : label; } -const THEME_STYLES = ["default", "quiet", "mauve", "sapphire", "peach", "teal"] as const; +const THEME_STYLES = ["default", "quiet", "mauve", "pink", "sapphire", "peach", "teal"] as const; const SLATE_COMPLETIONS = [ "session", "session mcp", "session skills", "session prompts", "session commands", "session tasks", "session image", "surfaces", diff --git a/pi-slate/tests/themes.test.ts b/pi-slate/tests/themes.test.ts index 854262d2..44350fde 100644 --- a/pi-slate/tests/themes.test.ts +++ b/pi-slate/tests/themes.test.ts @@ -86,7 +86,8 @@ test("bundled effort ramps start neutral and brighten toward each theme's border const expectedMax: Record = { "tokyo-night": "#6785ca", "black-metal": "#8a8a8a", "catppuccin-mocha-default": "#9ea7da", "catppuccin-mocha-quiet": "#9da3bd", - "catppuccin-mocha-mauve": "#af96d5", "catppuccin-mocha-sapphire": "#72adcd", + "catppuccin-mocha-mauve": "#af96d5", "catppuccin-mocha-pink": "#cca9ca", + "catppuccin-mocha-sapphire": "#72adcd", "catppuccin-mocha-peach": "#cf9f87", "catppuccin-mocha-teal": "#88c0bd", }; for (const theme of allSlateThemes()) { diff --git a/pi-slate/themes/catppuccin-mocha-pink.json b/pi-slate/themes/catppuccin-mocha-pink.json new file mode 100644 index 00000000..07e0441d --- /dev/null +++ b/pi-slate/themes/catppuccin-mocha-pink.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "catppuccin-mocha-pink", + "vars": { + "rosewater": "#f5e0dc", + "flamingo": "#f2cdcd", + "pink": "#f5c2e7", + "mauve": "#cba6f7", + "red": "#f38ba8", + "maroon": "#eba0ac", + "peach": "#fab387", + "yellow": "#f9e2af", + "green": "#a6e3a1", + "teal": "#94e2d5", + "sky": "#89dceb", + "sapphire": "#74c7ec", + "blue": "#89b4fa", + "lavender": "#b4befe", + "text": "#cdd6f4", + "subtext1": "#bac2de", + "subtext0": "#a6adc8", + "overlay2": "#9399b2", + "overlay1": "#7f849c", + "overlay0": "#6c7086", + "surface2": "#585b70", + "surface1": "#45475a", + "surface0": "#313244", + "base": "#1e1e2e", + "mantle": "#181825", + "crust": "#11111b", + "accent": "#f5c2e7", + "border": "#6c7086", + "heading": "#f5c2e7", + "panel": "#181825", + "surface": "#313244", + "thinkingOff": "#6c7086", + "thinkingMinimal": "#7a7890", + "thinkingLow": "#878099", + "thinkingMedium": "#988aa5", + "thinkingHigh": "#a894b1", + "thinkingXhigh": "#ba9fbd", + "thinkingMax": "#cca9ca" + }, + "colors": { + "accent": "accent", + "border": "border", + "borderAccent": "accent", + "borderMuted": "surface1", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "subtext0", + "dim": "overlay1", + "text": "text", + "thinkingText": "overlay2", + "selectedBg": "surface1", + "scrollbarTrack": "surface0", + "scrollbarThumb": "overlay1", + "searchMatchBg": "teal", + "searchMatchText": "text", + "userMessageBg": "surface", + "userMessageText": "text", + "customMessageBg": "panel", + "customMessageText": "text", + "customMessageLabel": "accent", + "toolPendingBg": "surface", + "toolSuccessBg": "panel", + "toolErrorBg": "crust", + "toolTitle": "text", + "toolOutput": "subtext0", + "mdHeading": "heading", + "mdLink": "accent", + "mdLinkUrl": "overlay1", + "mdCode": "accent", + "mdCodeBlock": "text", + "mdCodeBlockBorder": "overlay0", + "mdQuote": "overlay2", + "mdQuoteBorder": "overlay0", + "mdHr": "overlay0", + "mdListBullet": "accent", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "overlay2", + "syntaxComment": "overlay2", + "syntaxKeyword": "mauve", + "syntaxFunction": "blue", + "syntaxVariable": "text", + "syntaxString": "green", + "syntaxNumber": "peach", + "syntaxType": "yellow", + "syntaxOperator": "sky", + "syntaxPunctuation": "overlay2", + "thinkingOff": "thinkingOff", + "thinkingMinimal": "thinkingMinimal", + "thinkingLow": "thinkingLow", + "thinkingMedium": "thinkingMedium", + "thinkingHigh": "thinkingHigh", + "thinkingXhigh": "thinkingXhigh", + "thinkingMax": "thinkingMax", + "bashMode": "green" + }, + "export": { + "pageBg": "#1e1e2e", + "cardBg": "#181825", + "infoBg": "#11111b" + } +} From 2ae97ed6ad00f03333543b473c340f58fc465e41 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Wed, 28 Oct 2026 02:21:53 +0530 Subject: [PATCH 02/14] pi-slate: select the pink theme by its full name --- pi-slate/CHANGELOG.md | 2 +- pi-slate/README.md | 4 ++-- pi-slate/extensions/pi-slate/catppuccin.ts | 17 ++++++++++++++--- pi-slate/extensions/pi-slate/layout.ts | 4 ++-- pi-slate/tests/catppuccin.test.ts | 3 +++ pi-slate/tests/layout.test.mjs | 8 ++++++++ 6 files changed, 30 insertions(+), 8 deletions(-) diff --git a/pi-slate/CHANGELOG.md b/pi-slate/CHANGELOG.md index 410f6352..b4ff61d2 100644 --- a/pi-slate/CHANGELOG.md +++ b/pi-slate/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- `catppuccin-mocha-pink` theme, selectable with `/slate theme pink` or `/slate style pink`. +- `catppuccin-mocha-pink` theme, selected by its full name with `/slate theme catppuccin-mocha-pink` or `/slate style catppuccin-mocha-pink`. ## 0.1.18 diff --git a/pi-slate/README.md b/pi-slate/README.md index 7a9ba95a..31daf4c6 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -74,7 +74,7 @@ Slate supports the following themes and can be changed with `/slate theme | `black-metal` | ⬛ | | `quiet` | ⬜ | | `mauve` | 🟪 | -| `pink` | 🩷 | +| `catppuccin-mocha-pink` | 🩷 | | `sapphire` | 🟦 | | `peach` | 🟧 | | `teal` | 🟩 | @@ -126,7 +126,7 @@ These work when `editor` is selected. No terminal configuration. | Message length | `/slate message-length [default\|all\|]` | How many chat messages stay on screen. `default` is 100. | | Density | `/slate density [comfortable\|compact]` | Comfortable shows a › prompt in the composer; compact is tighter. | | Composer metadata | `/slate footer [standard\|minimal]` | Legacy command alias: standard shows model and thinking on the composer; minimal hides them. Not footer-slot ownership. | -| Theme | `/slate theme [default\|tokyo-night\|black-metal\|quiet\|mauve\|pink\|sapphire\|peach\|teal]` | Default restores Pi's system theme. Tokyo Night and Black Metal are explicit; other styles select Catppuccin; `/slate style` retains Catppuccin aliases. | +| Theme | `/slate theme [default\|tokyo-night\|black-metal\|quiet\|mauve\|catppuccin-mocha-pink\|sapphire\|peach\|teal]` | Default restores Pi's system theme. Tokyo Night and Black Metal are explicit; other styles select Catppuccin; `/slate style` retains Catppuccin aliases. | | Bugs | `/slate bug [file\|open]` | Copy a bug report, or open the npm package page. | ## Independent Surfaces diff --git a/pi-slate/extensions/pi-slate/catppuccin.ts b/pi-slate/extensions/pi-slate/catppuccin.ts index edd02497..91e60598 100644 --- a/pi-slate/extensions/pi-slate/catppuccin.ts +++ b/pi-slate/extensions/pi-slate/catppuccin.ts @@ -14,7 +14,7 @@ export const STYLE_LABELS: Record = { default: "Default", quiet: "Quiet", mauve: "Mauve", - pink: "Pink", + pink: "catppuccin-mocha-pink", sapphire: "Sapphire", peach: "Peach", teal: "Teal", @@ -64,6 +64,14 @@ export function parseFlavor(raw: string): Flavor | undefined { export function parseStyle(raw: string): Style | undefined { const value = raw.trim().toLowerCase(); + if (value === "canonical") return "default"; + // Labels are the user-facing names: plain style keys except Pink, which is + // selected by its full theme name (`catppuccin-mocha-pink`). + return STYLES.find((style) => STYLE_LABELS[style].toLowerCase() === value); +} + +/** Theme files encode the style key (e.g. `catppuccin-mocha-pink`), not the label. */ +function styleFromThemeName(value: string): Style | undefined { if (value === "canonical") return "default"; return STYLES.find((style) => style === value); } @@ -73,7 +81,7 @@ export function parseCatppuccinTheme(name: string | undefined): { flavor: Flavor const match = /^catppuccin-([a-z]+)-([a-z]+)$/.exec(name); if (!match) return undefined; const flavor = parseFlavor(match[1]); - const style = parseStyle(match[2]); + const style = styleFromThemeName(match[2]); if (!flavor || !style) return undefined; return { flavor, style }; } @@ -90,7 +98,10 @@ export function resolveCatppuccinTheme( } export function themeMessage(flavor: Flavor, style: Style): string { - return `Theme set to ${FLAVOR_LABELS[flavor]} · ${STYLE_LABELS[style]}`; + const label = STYLE_LABELS[style]; + return label === catppuccinThemeName(flavor, style) + ? `Theme set to ${label}` + : `Theme set to ${FLAVOR_LABELS[flavor]} · ${label}`; } export const THEME_SCHEMA = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json"; diff --git a/pi-slate/extensions/pi-slate/layout.ts b/pi-slate/extensions/pi-slate/layout.ts index 6ab7f439..b93b58a1 100644 --- a/pi-slate/extensions/pi-slate/layout.ts +++ b/pi-slate/extensions/pi-slate/layout.ts @@ -303,7 +303,7 @@ export const SLATE_VERSION = JSON.parse( ).version as string; export const SLATE_USAGE = - "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme [default|tokyo-night|black-metal|quiet|mauve|pink|sapphire|peach|teal] | style [default|quiet|mauve|pink|sapphire|peach|teal] | bug [file|open]"; + "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme [default|tokyo-night|black-metal|quiet|mauve|catppuccin-mocha-pink|sapphire|peach|teal] | style [default|quiet|mauve|catppuccin-mocha-pink|sapphire|peach|teal] | bug [file|open]"; export function withCurrent(label: string, current: boolean): string { return current ? `${label} (current)` : label; @@ -313,7 +313,7 @@ export function withoutCurrent(label: string): string { return label.endsWith(" (current)") ? label.slice(0, -" (current)".length) : label; } -const THEME_STYLES = ["default", "quiet", "mauve", "pink", "sapphire", "peach", "teal"] as const; +const THEME_STYLES = ["default", "quiet", "mauve", "catppuccin-mocha-pink", "sapphire", "peach", "teal"] as const; const SLATE_COMPLETIONS = [ "session", "session mcp", "session skills", "session prompts", "session commands", "session tasks", "session image", "surfaces", diff --git a/pi-slate/tests/catppuccin.test.ts b/pi-slate/tests/catppuccin.test.ts index e3c74058..4e978405 100644 --- a/pi-slate/tests/catppuccin.test.ts +++ b/pi-slate/tests/catppuccin.test.ts @@ -38,6 +38,8 @@ test("flavor and style names stay official", () => { assert.equal(parseFlavor("frappe"), undefined); assert.equal(parseStyle("Mauve"), "mauve"); assert.equal(parseStyle("canonical"), "default"); + assert.equal(parseStyle("catppuccin-mocha-pink"), "pink"); + assert.equal(parseStyle("pink"), undefined); assert.equal(parseFlavor("ink"), undefined); assert.equal(parseStyle("ember"), undefined); }); @@ -48,6 +50,7 @@ test("theme names encode flavor and style", () => { flavor: "mocha", style: "quiet", }); + assert.deepEqual(parseCatppuccinTheme("catppuccin-mocha-pink"), { flavor: "mocha", style: "pink" }); assert.equal(parseCatppuccinTheme("catppuccin-latte-quiet"), undefined); assert.equal(parseCatppuccinTheme("pi-slate"), undefined); assert.deepEqual(resolveCatppuccinTheme("pi-slate"), { diff --git a/pi-slate/tests/layout.test.mjs b/pi-slate/tests/layout.test.mjs index 4a252f2c..a8ca7553 100644 --- a/pi-slate/tests/layout.test.mjs +++ b/pi-slate/tests/layout.test.mjs @@ -289,6 +289,14 @@ test("/slate args route density, footer, and width", () => { assert.deepEqual(parseSlateArgs("flavor mocha"), { ok: false }); assert.deepEqual(parseSlateArgs("diff"), { ok: false }); assert.deepEqual(parseSlateArgs("style sapphire"), { ok: true, kind: "style", value: "sapphire" }); + assert.deepEqual(parseSlateArgs("theme catppuccin-mocha-pink"), { ok: true, kind: "theme", style: "pink" }); + assert.deepEqual(parseSlateArgs("theme mocha catppuccin-mocha-pink"), { ok: true, kind: "theme", flavor: "mocha", style: "pink" }); + assert.deepEqual(parseSlateArgs("style catppuccin-mocha-pink"), { ok: true, kind: "style", value: "pink" }); + assert.deepEqual(parseSlateArgs("theme pink"), { ok: false }); + assert.deepEqual(slateArgumentCompletions("theme cat"), [ + { value: "theme catppuccin-mocha-pink", label: "theme catppuccin-mocha-pink" }, + ]); + assert.deepEqual(slateArgumentCompletions("theme p"), [{ value: "theme peach", label: "theme peach" }]); assert.deepEqual(parseSlateArgs("theme nope"), { ok: false }); assert.deepEqual(parseSlateArgs("theme mocha mauve extra"), { ok: false }); assert.deepEqual(parseSlateArgs("bug nope"), { ok: false }); From 35957370ec0c5dfe29b39ba4fc0b4053cba03b1b Mon Sep 17 00:00:00 2001 From: GaganSD Date: Wed, 28 Oct 2026 02:42:18 +0530 Subject: [PATCH 03/14] Add /theme slate entry point while retaining legacy commands --- pi-slate/CHANGELOG.md | 2 ++ pi-slate/README.md | 4 ++-- pi-slate/extensions/pi-slate/index.ts | 11 +++++++++++ pi-slate/tests/surfaces-install.test.ts | 14 ++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/pi-slate/CHANGELOG.md b/pi-slate/CHANGELOG.md index b4ff61d2..ece5ada5 100644 --- a/pi-slate/CHANGELOG.md +++ b/pi-slate/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- `/theme slate` opens the Slate theme menu; `/slate theme [name]` remains supported. Pi's `/settings` theme selection is unchanged. + - `catppuccin-mocha-pink` theme, selected by its full name with `/slate theme catppuccin-mocha-pink` or `/slate style catppuccin-mocha-pink`. ## 0.1.18 diff --git a/pi-slate/README.md b/pi-slate/README.md index 31daf4c6..068d8ca2 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -66,7 +66,7 @@ Other extensions can publish detached terminals or jobs to the sidebar's task sh ### Themes -Slate supports the following themes and can be changed with `/slate theme `: +Open `/theme slate` to choose a Slate theme. Direct selection with `/slate theme ` remains supported: | Themes | Accent | | --- | --- | @@ -80,7 +80,7 @@ Slate supports the following themes and can be changed with `/slate theme | `teal` | 🟩 | | `default` | Pi's system theme | -Black Metal is an optional bundled theme, not an install default: pick it with `/slate theme black-metal` or the `/slate theme` menu. Every selection is saved and also appears in `/settings`. +Black Metal is an optional bundled theme, not an install default: pick it with `/slate theme black-metal` or `/theme slate` (`/slate theme` also opens the menu). Every selection is saved and also appears in `/settings`. With `/slate effort-border on`, bundled themes use a restrained effort ramp from their neutral border toward their border accent: blue for Tokyo Night, the selected Catppuccin style, cool grays for Quiet, and grayscale for Black Metal. Higher effort increases intensity instead of switching to unrelated hues; the effort label remains the precise cue. The sidebar stays neutral, bash mode keeps its separate color, and external themes retain their own effort colors. These are native theme tokens, so Pi's stock editor also uses the ramp when a bundled Slate theme is selected. Effort-colored Slate frames remain off by default. diff --git a/pi-slate/extensions/pi-slate/index.ts b/pi-slate/extensions/pi-slate/index.ts index cf7c2001..84f0839c 100644 --- a/pi-slate/extensions/pi-slate/index.ts +++ b/pi-slate/extensions/pi-slate/index.ts @@ -1115,6 +1115,17 @@ export default function piSlate(pi: ExtensionAPI): void { } }; + // Pi 1.4 has no built-in /theme command; core theme selection lives in /settings. + // Keep this namespace narrow rather than replacing core settings behavior. + pi.registerCommand("theme", { + description: "Open the Slate theme selector with /theme slate", + getArgumentCompletions: (prefix) => "slate".startsWith(prefix.trim()) ? [{ value: "slate", label: "slate" }] : null, + handler: async (args, ctx) => { + if (args.trim() !== "slate") { ctx.ui.notify("Usage: /theme slate (Pi themes are also available in /settings)", "info"); return; } + await pickTheme(ctx); + }, + }); + pi.registerCommand("slate", { description: "Session dashboard, surfaces, density, composer metadata, sidebar, focused mode, PID display, effort border color, header logo, progress indicator, message length, theme, or file a bug", getArgumentCompletions: slateArgumentCompletions, diff --git a/pi-slate/tests/surfaces-install.test.ts b/pi-slate/tests/surfaces-install.test.ts index d83ae03a..31eae2ac 100644 --- a/pi-slate/tests/surfaces-install.test.ts +++ b/pi-slate/tests/surfaces-install.test.ts @@ -392,6 +392,20 @@ test("system default and explicit Tokyo Night preserve unrelated native settings assert.deepEqual(saved.packages, ["keep-this-package"]); }); +test("/theme slate routes only its namespace and preserves legacy theme commands", async t => { + const f = await fixture(t, JSON.stringify({ surfaces: [] })); + const menus: string[] = []; + t.mock.method(f.ctx.ui, "select", async (title: string) => { menus.push(title); return undefined; }); + await f.commands.get("theme")!.handler("slate", f.ctx); + await f.commands.get("slate")!.handler("theme", f.ctx); + assert.deepEqual(menus, ["Theme", "Theme"]); + await f.commands.get("theme")!.handler("", f.ctx); + await f.commands.get("theme")!.handler("dark", f.ctx); + await f.commands.get("theme")!.handler("slate extra", f.ctx); + assert.deepEqual(menus, ["Theme", "Theme"], "unrelated arguments never open the Slate picker"); + assert.equal(f.commands.has("settings"), false, "core settings remain untouched"); +}); + test("Black Metal is selectable by command and from the theme picker", async t => { const f = await fixture(t, JSON.stringify({ surfaces: ["sidebar"] })); const settingsPath = join(dirname(f.path), "settings.json"); From eeedf863a81dfa8a9a841c9cb7e463cd946da1e8 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Wed, 28 Oct 2026 02:52:46 +0530 Subject: [PATCH 04/14] Add live theme selector with palette variants and cancel restoration --- pi-slate/CHANGELOG.md | 4 +- pi-slate/README.md | 15 +- pi-slate/extensions/pi-slate/catppuccin.ts | 6 +- pi-slate/extensions/pi-slate/index.ts | 25 +-- pi-slate/extensions/pi-slate/layout.ts | 4 +- .../extensions/pi-slate/theme-selector.ts | 138 ++++++++++++++++ pi-slate/tests/surfaces-install.test.ts | 5 +- pi-slate/tests/theme-selector.test.ts | 154 ++++++++++++++++++ pi-slate/tests/themes.test.ts | 1 + .../themes/catppuccin-mocha-lavender.json | 107 ++++++++++++ pi-slate/themes/catppuccin-mocha-red.json | 107 ++++++++++++ 11 files changed, 538 insertions(+), 28 deletions(-) create mode 100644 pi-slate/extensions/pi-slate/theme-selector.ts create mode 100644 pi-slate/tests/theme-selector.test.ts create mode 100644 pi-slate/themes/catppuccin-mocha-lavender.json create mode 100644 pi-slate/themes/catppuccin-mocha-red.json diff --git a/pi-slate/CHANGELOG.md b/pi-slate/CHANGELOG.md index ece5ada5..cc291b1e 100644 --- a/pi-slate/CHANGELOG.md +++ b/pi-slate/CHANGELOG.md @@ -4,7 +4,9 @@ ### Added -- `/theme slate` opens the Slate theme menu; `/slate theme [name]` remains supported. Pi's `/settings` theme selection is unchanged. +- `/theme slate` opens a centered live-preview selector: up/down changes themes, left/right changes labeled palette variants, Enter saves, and Escape restores the original visible theme without writing settings. `/slate theme [name]` remains supported; Pi's `/settings` is unchanged. +- Catppuccin Mocha Red (`#f38ba8`) and Lavender (`#b4befe`) variants alongside existing Pink; variant choices survive navigation within the selector. +- On Pi 1.4, cancelling after preview restores the original palette but automatic terminal-appearance following resumes only after `/reload` (public theme-instance API limitation). Saved theme and light/dark settings are untouched. - `catppuccin-mocha-pink` theme, selected by its full name with `/slate theme catppuccin-mocha-pink` or `/slate style catppuccin-mocha-pink`. diff --git a/pi-slate/README.md b/pi-slate/README.md index 068d8ca2..e2edfd4c 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -66,7 +66,15 @@ Other extensions can publish detached terminals or jobs to the sidebar's task sh ### Themes -Open `/theme slate` to choose a Slate theme. Direct selection with `/slate theme ` remains supported: +Open `/theme slate` for a centered theme selector with live preview: + +- **↑ / ↓** selects and immediately applies a theme; **← / →** changes its labeled palette variant. +- Catppuccin variants include **Pink**, **Red** (`#f38ba8`), and **Lavender** (`#b4befe`), alongside the existing styles. Each theme remembers its variant while the selector is open. +- **Enter** confirms and saves; **Escape** (or **Ctrl+C**) restores the original visible theme without changing saved theme settings or Slate configuration. + +The selector requires interactive terminal mode. Pi 1.4 has no built-in `/theme` command; core theme selection remains in `/settings`. After previewing and cancelling, Pi's public theme-instance API restores the original palette but pauses automatic terminal-appearance following (including `system` and light/dark pairs) until `/reload`. Merely opening and cancelling without previewing does not affect appearance following. + +Direct selection with `/slate theme ` remains supported: | Themes | Accent | | --- | --- | @@ -75,12 +83,13 @@ Open `/theme slate` to choose a Slate theme. Direct selection with `/slate theme | `quiet` | ⬜ | | `mauve` | 🟪 | | `catppuccin-mocha-pink` | 🩷 | +| `red` / `lavender` | `#f38ba8` / `#b4befe` | | `sapphire` | 🟦 | | `peach` | 🟧 | | `teal` | 🟩 | | `default` | Pi's system theme | -Black Metal is an optional bundled theme, not an install default: pick it with `/slate theme black-metal` or `/theme slate` (`/slate theme` also opens the menu). Every selection is saved and also appears in `/settings`. +Black Metal is an optional bundled theme, not an install default: pick it with `/slate theme black-metal` or `/theme slate` (`/slate theme` also opens the menu). Confirmed selections are saved and also appear in `/settings`. With `/slate effort-border on`, bundled themes use a restrained effort ramp from their neutral border toward their border accent: blue for Tokyo Night, the selected Catppuccin style, cool grays for Quiet, and grayscale for Black Metal. Higher effort increases intensity instead of switching to unrelated hues; the effort label remains the precise cue. The sidebar stays neutral, bash mode keeps its separate color, and external themes retain their own effort colors. These are native theme tokens, so Pi's stock editor also uses the ramp when a bundled Slate theme is selected. Effort-colored Slate frames remain off by default. @@ -126,7 +135,7 @@ These work when `editor` is selected. No terminal configuration. | Message length | `/slate message-length [default\|all\|]` | How many chat messages stay on screen. `default` is 100. | | Density | `/slate density [comfortable\|compact]` | Comfortable shows a › prompt in the composer; compact is tighter. | | Composer metadata | `/slate footer [standard\|minimal]` | Legacy command alias: standard shows model and thinking on the composer; minimal hides them. Not footer-slot ownership. | -| Theme | `/slate theme [default\|tokyo-night\|black-metal\|quiet\|mauve\|catppuccin-mocha-pink\|sapphire\|peach\|teal]` | Default restores Pi's system theme. Tokyo Night and Black Metal are explicit; other styles select Catppuccin; `/slate style` retains Catppuccin aliases. | +| Theme | `/theme slate` or `/slate theme [default\|tokyo-night\|black-metal\|quiet\|mauve\|catppuccin-mocha-pink\|red\|lavender\|sapphire\|peach\|teal]` | Default restores Pi's system theme. Tokyo Night and Black Metal are explicit; other styles select Catppuccin; `/slate style` retains Catppuccin aliases. | | Bugs | `/slate bug [file\|open]` | Copy a bug report, or open the npm package page. | ## Independent Surfaces diff --git a/pi-slate/extensions/pi-slate/catppuccin.ts b/pi-slate/extensions/pi-slate/catppuccin.ts index 91e60598..2ce12ed8 100644 --- a/pi-slate/extensions/pi-slate/catppuccin.ts +++ b/pi-slate/extensions/pi-slate/catppuccin.ts @@ -1,7 +1,7 @@ import { effortPalette } from "./theme-palette.ts"; export const FLAVORS = ["mocha"] as const; -export const STYLES = ["default", "quiet", "mauve", "pink", "sapphire", "peach", "teal"] as const; +export const STYLES = ["default", "quiet", "mauve", "pink", "red", "lavender", "sapphire", "peach", "teal"] as const; export type Flavor = (typeof FLAVORS)[number]; export type Style = (typeof STYLES)[number]; @@ -15,6 +15,8 @@ export const STYLE_LABELS: Record = { quiet: "Quiet", mauve: "Mauve", pink: "catppuccin-mocha-pink", + red: "Red", + lavender: "Lavender", sapphire: "Sapphire", peach: "Peach", teal: "Teal", @@ -48,6 +50,8 @@ const STYLE_ROLES: Record = { @@ -875,15 +876,16 @@ export default function piSlate(pi: ExtensionAPI): void { return STYLES.find((style) => STYLE_LABELS[style] === key); }; - const applyNamedTheme = (ctx: ExtensionContext, name: string, message?: string): void => { + const applyNamedTheme = (ctx: ExtensionContext, name: string, message?: string): boolean => { const result = ctx.ui.setTheme(name); if (!result.success) { ctx.ui.notify(result.error ?? `Could not load ${name}. Run /reload first.`, "error"); - return; + return false; } persistTheme(ctx.cwd, name); requestRender(true); ctx.ui.notify(message ?? `Theme set to ${name === SLATE_NATIVE_THEME ? "system default" : name}`, "info"); + return true; }; const applyCatppuccin = (ctx: ExtensionContext, flavor: Flavor, style: Style): void => { @@ -891,22 +893,7 @@ export default function piSlate(pi: ExtensionAPI): void { applyNamedTheme(ctx, next.name, themeMessage(next.flavor, next.style)); }; - const pickTheme = async (ctx: ExtensionContext): Promise => { - const mocha = currentCatppuccin(ctx).style; - const value = await ctx.ui.select("Theme", [ - withCurrent("System default", ctx.ui.theme.name === SLATE_NATIVE_THEME), - withCurrent("Tokyo Night", ctx.ui.theme.name === SLATE_THEME), - withCurrent("Black Metal", ctx.ui.theme.name === "black-metal"), - ...STYLES.map((style) => withCurrent(STYLE_LABELS[style], ctx.ui.theme.name?.startsWith("catppuccin-") === true && style === mocha)), - ]); - if (!value) return; - const key = withoutCurrent(value); - if (key === "System default") { applyNamedTheme(ctx, SLATE_NATIVE_THEME); return; } - if (key === "Tokyo Night") { applyNamedTheme(ctx, SLATE_THEME, "Theme set to Tokyo Night"); return; } - if (key === "Black Metal") { applyNamedTheme(ctx, "black-metal", "Theme set to Black Metal"); return; } - const style = STYLES.find((item) => STYLE_LABELS[item] === key); - if (style) applyCatppuccin(ctx, currentCatppuccin(ctx).flavor, style); - }; + const pickTheme = (ctx: ExtensionContext): Promise => selectSlateTheme(ctx, name => applyNamedTheme(ctx, name)); const pickWidth = async (ctx: ExtensionContext): Promise<{ picked: true; width?: number } | undefined> => { const percent = sidebar.preferredWidth ?? config.sidebarPercent; diff --git a/pi-slate/extensions/pi-slate/layout.ts b/pi-slate/extensions/pi-slate/layout.ts index b93b58a1..28f64217 100644 --- a/pi-slate/extensions/pi-slate/layout.ts +++ b/pi-slate/extensions/pi-slate/layout.ts @@ -303,7 +303,7 @@ export const SLATE_VERSION = JSON.parse( ).version as string; export const SLATE_USAGE = - "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme [default|tokyo-night|black-metal|quiet|mauve|catppuccin-mocha-pink|sapphire|peach|teal] | style [default|quiet|mauve|catppuccin-mocha-pink|sapphire|peach|teal] | bug [file|open]"; + "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme [default|tokyo-night|black-metal|quiet|mauve|catppuccin-mocha-pink|red|lavender|sapphire|peach|teal] | style [default|quiet|mauve|catppuccin-mocha-pink|red|lavender|sapphire|peach|teal] | bug [file|open]"; export function withCurrent(label: string, current: boolean): string { return current ? `${label} (current)` : label; @@ -313,7 +313,7 @@ export function withoutCurrent(label: string): string { return label.endsWith(" (current)") ? label.slice(0, -" (current)".length) : label; } -const THEME_STYLES = ["default", "quiet", "mauve", "catppuccin-mocha-pink", "sapphire", "peach", "teal"] as const; +const THEME_STYLES = ["default", "quiet", "mauve", "catppuccin-mocha-pink", "red", "lavender", "sapphire", "peach", "teal"] as const; const SLATE_COMPLETIONS = [ "session", "session mcp", "session skills", "session prompts", "session commands", "session tasks", "session image", "surfaces", diff --git a/pi-slate/extensions/pi-slate/theme-selector.ts b/pi-slate/extensions/pi-slate/theme-selector.ts new file mode 100644 index 00000000..8a77083e --- /dev/null +++ b/pi-slate/extensions/pi-slate/theme-selector.ts @@ -0,0 +1,138 @@ +import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; +import { matchesKey, parseColor, truncateToWidth, visibleWidth, type Component, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui"; +import { buildCatppuccinTheme, catppuccinThemeName, parseCatppuccinTheme, STYLES, STYLE_LABELS } from "./catppuccin.ts"; + +export type ThemeVariant = { name: string; label: string; color?: string }; +export type ThemeChoice = { label: string; variants: ThemeVariant[] }; + +export function slateThemeChoices(initial?: string): ThemeChoice[] { + const choices: ThemeChoice[] = [ + { label: "System default", variants: [{ name: "system", label: "Terminal palette" }] }, + { label: "Tokyo Night", variants: [{ name: "tokyo-night", label: "Default", color: "#7aa2f7" }] }, + { label: "Black Metal", variants: [{ name: "black-metal", label: "Default", color: "#dd9999" }] }, + { label: "catppuccin-mocha", variants: STYLES.map(style => ({ + name: catppuccinThemeName("mocha", style), label: style === "pink" ? "Pink" : STYLE_LABELS[style], + color: buildCatppuccinTheme("mocha", style).vars.accent, + })) }, + ]; + if (initial && !choices.some(choice => choice.variants.some(variant => variant.name === initial))) { + choices.unshift({ label: `${initial} (current)`, variants: [{ name: initial, label: "Current palette" }] }); + } + return choices; +} + +/** A single interaction; variant choices survive theme navigation without writing settings. */ +export class ThemeSelector implements Component { + private selected = 0; + private variants: number[]; + private error = ""; + private closed = false; + + private choices: ThemeChoice[]; + private tui: Pick; + private theme: () => Theme; + private keys: Pick; + private preview: (name: string) => string | undefined; + private done: (name: string | undefined) => void; + + constructor( + choices: ThemeChoice[], initial: string | undefined, + tui: Pick, + theme: () => Theme, keys: Pick, + preview: (name: string) => string | undefined, + done: (name: string | undefined) => void, + ) { + this.choices = choices; this.tui = tui; this.theme = theme; this.keys = keys; + this.preview = preview; this.done = done; + this.variants = choices.map(choice => Math.max(0, choice.variants.findIndex(variant => variant.name === initial))); + this.selected = Math.max(0, choices.findIndex(choice => choice.variants.some(variant => variant.name === initial))); + // Prefer the existing Pink palette for a new visit to Catppuccin. + if (!parseCatppuccinTheme(initial)) { + const index = choices.findIndex(choice => choice.label === "catppuccin-mocha"); + if (index >= 0) this.variants[index] = Math.max(0, choices[index]!.variants.findIndex(variant => variant.label === "Pink")); + } + } + + private current(): ThemeVariant { return this.choices[this.selected]!.variants[this.variants[this.selected]!]!; } + + handleInput(data: string): void { + if (this.closed) return; + if (this.keys.matches(data, "tui.select.cancel") || matchesKey(data, "ctrl+c")) { + this.closed = true; this.done(undefined); return; + } + if (this.keys.matches(data, "tui.select.confirm")) { + if (!this.error) { this.closed = true; this.done(this.current().name); } + return; + } + if (this.keys.matches(data, "tui.select.up")) this.selected = (this.selected + this.choices.length - 1) % this.choices.length; + else if (this.keys.matches(data, "tui.select.down")) this.selected = (this.selected + 1) % this.choices.length; + else if (matchesKey(data, "left") || matchesKey(data, "right")) { + const count = this.choices[this.selected]!.variants.length; + if (count === 1) return; + this.variants[this.selected] = (this.variants[this.selected]! + (matchesKey(data, "right") ? 1 : count - 1)) % count; + } else return; + this.error = this.preview(this.current().name) ?? ""; + this.tui.requestRender(); + } + + render(width: number): string[] { + const th = this.theme(); + const current = this.current(); + const choice = this.choices[this.selected]!; + const inner = Math.max(0, width - 4); + const swatch = (variant: ThemeVariant) => variant.color + ? th.style("●", { fg: parseColor(variant.color) }) : th.fg("muted", "●"); + const lines = [th.bold("Slate themes"), ...this.choices.map((item, index) => + index === this.selected ? th.fg("accent", `› ${item.label}`) : th.fg("muted", ` ${item.label}`)), + th.fg("accent", `${swatch(current)} ${current.label}`)]; + if (choice.variants.length > 1) { + // Wrap labeled swatches instead of clipping the active variant off-screen. + let row = ""; + for (const variant of choice.variants) { + const label = `${swatch(variant)} ${variant.name === current.name ? `[${variant.label}]` : variant.label} `; + if (row && visibleWidth(row + label) > inner) { lines.push(row); row = ""; } + row += label; + } + if (row) lines.push(row); + } + if (this.error) lines.push(th.fg("error", this.error)); + lines.push(th.fg("dim", "↑↓ theme · ←→ variant"), th.fg("dim", "Enter save · Esc restore")); + const available = Math.max(1, this.tui.terminal.rows - 2); + // Very short terminals retain selection and key hints rather than cutting off controls. + const content = lines.length + 2 <= available ? lines + : [th.bold("Slate themes"), th.fg("accent", `${choice.label} · ${current.label}`), ...lines.slice(-2)]; + if (width < 4) return content.slice(0, available).map(line => truncateToWidth(line, width, "")); + const border = (text: string) => th.fg("border", text); + return [border(`╭${"─".repeat(width - 2)}╮`), + ...content.slice(0, Math.max(0, available - 2)).map(line => border("│ ") + truncateToWidth(line, inner, "…", true) + border(" │")), + border(`╰${"─".repeat(width - 2)}╯`)]; + } + + invalidate(): void {} // Render uses the live theme and current terminal size; no styled cache. +} + +export async function selectSlateTheme(ctx: ExtensionContext, save: (name: string) => boolean): Promise { + if (ctx.mode !== "tui") { ctx.ui.notify("/theme slate requires interactive terminal mode", "error"); return; } + const original = ctx.ui.theme; + let confirmed = false; + let previewed = false; + try { + const selected = await ctx.ui.custom((tui, _theme, keys, done) => new ThemeSelector( + slateThemeChoices(original.name), original.name, tui, () => ctx.ui.theme, keys, + name => { + // Pi persists string setTheme calls. Theme instances preview without touching settings. + const theme = ctx.ui.getTheme(name); + if (!theme) return `Could not load ${name}. Run /reload.`; + previewed = true; + const result = ctx.ui.setTheme(theme); + return result.success ? undefined : result.error ?? `Could not preview ${name}`; + }, done, + ), { overlay: true, overlayOptions: { anchor: "center", width: 58, maxHeight: "100%" } }); + if (selected) confirmed = save(selected); + } finally { + if (!confirmed && previewed) { + const result = ctx.ui.setTheme(original); + if (!result.success) ctx.ui.notify(result.error ?? "Could not restore original theme", "error"); + } + } +} diff --git a/pi-slate/tests/surfaces-install.test.ts b/pi-slate/tests/surfaces-install.test.ts index 31eae2ac..514dc86d 100644 --- a/pi-slate/tests/surfaces-install.test.ts +++ b/pi-slate/tests/surfaces-install.test.ts @@ -395,7 +395,8 @@ test("system default and explicit Tokyo Night preserve unrelated native settings test("/theme slate routes only its namespace and preserves legacy theme commands", async t => { const f = await fixture(t, JSON.stringify({ surfaces: [] })); const menus: string[] = []; - t.mock.method(f.ctx.ui, "select", async (title: string) => { menus.push(title); return undefined; }); + t.mock.method(f.ctx.ui, "custom", async () => { menus.push("Theme"); return undefined; }); + t.mock.method(f.ctx.ui, "setTheme", () => ({ success: true })); await f.commands.get("theme")!.handler("slate", f.ctx); await f.commands.get("slate")!.handler("theme", f.ctx); assert.deepEqual(menus, ["Theme", "Theme"]); @@ -414,7 +415,7 @@ test("Black Metal is selectable by command and from the theme picker", async t = await f.commands.get("slate")!.handler("theme black-metal", f.ctx); assert.deepEqual(themes, ["black-metal"]); assert.equal(JSON.parse(await readFile(settingsPath, "utf8")).theme, "black-metal"); - f.choices.push("Black Metal"); + t.mock.method(f.ctx.ui, "custom", async () => "black-metal"); await f.commands.get("slate")!.handler("theme", f.ctx); assert.deepEqual(themes, ["black-metal", "black-metal"], "picker lists Black Metal"); }); diff --git a/pi-slate/tests/theme-selector.test.ts b/pi-slate/tests/theme-selector.test.ts new file mode 100644 index 00000000..f6efec27 --- /dev/null +++ b/pi-slate/tests/theme-selector.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; +import { visibleWidth, type TUI } from "@earendil-works/pi-tui"; +import { KeybindingsManager } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/keybindings.js"; +import { selectSlateTheme, slateThemeChoices, ThemeSelector } from "../extensions/pi-slate/theme-selector.ts"; +import { InteractiveMode } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js"; +import { BUILTIN_SLASH_COMMANDS } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js"; + +const theme = { name: "external", fg: (_: string, text: string) => text, bold: (text: string) => text, + style: (text: string) => text } as unknown as Theme; +const keys = new KeybindingsManager(); +const up = "\x1b[A", down = "\x1b[B", right = "\x1b[C", left = "\x1b[D"; + +function fixture(initial = "catppuccin-mocha-pink") { + const previews: string[] = []; + const done: Array = []; + const terminal = { rows: 40, columns: 80 }; + let renders = 0; + const selector = new ThemeSelector(slateThemeChoices(), initial, { terminal: terminal as TUI["terminal"], requestRender: () => { renders++; } }, + () => theme, keys, name => { previews.push(name); return undefined; }, value => done.push(value)); + return { selector, previews, done, terminal, renders: () => renders }; +} + +test("theme navigation previews immediately and remembers each theme's variant", () => { + const f = fixture(); + assert.deepEqual(f.previews, [], "opening preserves original theme"); + f.selector.handleInput(right); + assert.deepEqual(f.previews, ["catppuccin-mocha-red"]); + assert.match(f.selector.render(58).join("\n"), /\[Red\]/); + f.selector.handleInput(right); + assert.equal(f.previews.at(-1), "catppuccin-mocha-lavender"); + f.selector.handleInput(up); + assert.equal(f.previews.at(-1), "black-metal"); + f.selector.handleInput(right); + assert.equal(f.previews.length, 3, "single-palette themes ignore horizontal navigation"); + f.selector.handleInput(down); + assert.equal(f.previews.at(-1), "catppuccin-mocha-lavender"); + f.selector.handleInput(left); + assert.equal(f.previews.at(-1), "catppuccin-mocha-red"); + f.selector.handleInput("\r"); + f.selector.handleInput(down); + assert.deepEqual(f.done, ["catppuccin-mocha-red"]); + assert.equal(f.renders(), 5); +}); + +test("selector wraps, cancels, and keeps labels and controls bounded after resizing", () => { + const f = fixture("system"); + f.selector.handleInput(up); + assert.equal(f.previews.at(-1), "catppuccin-mocha-pink"); + for (const width of [100, 58, 32, 20, 4, 3, 1]) { + for (const rows of [40, 16, 8, 3]) { + f.terminal.rows = rows; + const lines = f.selector.render(width); + assert(lines.every(line => visibleWidth(line) <= width), `${width}x${rows}`); + assert(lines.length <= Math.max(2, rows - 2), `${width}x${rows} height`); + } + } + f.terminal.rows = 8; + assert.match(f.selector.render(58).join("\n"), /catppuccin-mocha · Pink/); + assert.match(f.selector.render(58).join("\n"), /Esc restore/); + f.selector.handleInput("\x1b"); + assert.deepEqual(f.done, [undefined]); +}); + +test("an external original theme stays visibly selected until navigation", () => { + const choices = slateThemeChoices("external"); + assert.equal(choices[0]!.variants[0]!.name, "external"); + const selector = new ThemeSelector(choices, "external", { + terminal: { rows: 40 } as TUI["terminal"], requestRender() {}, + }, () => theme, keys, () => assert.fail("opening must not preview"), () => {}); + assert.match(selector.render(58).join("\n"), /› external \(current\)/); +}); + +test("requested palettes use the exact accents", () => { + const variants = slateThemeChoices().at(-1)!.variants; + assert.equal(variants.find(item => item.label === "Pink")!.color, "#f5c2e7"); + assert.equal(variants.find(item => item.label === "Red")!.color, "#f38ba8"); + assert.equal(variants.find(item => item.label === "Lavender")!.color, "#b4befe"); +}); + +for (const action of ["save", "cancel", "throw", "failed-save", "failed-preview"] as const) { + test(`selector transaction: ${action}`, async () => { + const applied: Array = []; + const saved: string[] = []; + let current = theme; + let disk = "light/dark"; + const ctx = { mode: "tui", ui: { + get theme() { return current; }, + getTheme: (name: string) => action === "failed-preview" ? undefined : { ...theme, name } as Theme, + setTheme: (value: Theme | string) => { + applied.push(value); + if (typeof value === "string") disk = value; + else current = value; + return { success: true }; + }, + notify() {}, + custom: async (factory: Parameters[0], options: unknown) => { + assert.deepEqual(options, { overlay: true, overlayOptions: { anchor: "center", width: 58, maxHeight: "100%" } }); + let result: unknown; + const component = await factory({ terminal: { rows: 40 }, requestRender() {} } as TUI, theme, keys, value => { result = value; }); + component.handleInput!(down); + assert.equal(disk, "light/dark", "previews never write settings, including auto-theme pairs"); + if (action === "throw") throw new Error("closed"); + component.handleInput!(action === "cancel" ? "\x1b" : "\r"); + return result; + }, + } } as unknown as ExtensionContext; + const run = () => selectSlateTheme(ctx, name => { + if (action === "failed-save") return false; + saved.push(name); ctx.ui.setTheme(name); return true; + }); + if (action === "throw") await assert.rejects(run, /closed/); else await run(); + if (action === "save") { + assert.deepEqual(saved, ["system"]); assert.equal(disk, "system"); + } else { + assert.deepEqual(saved, []); + if (action === "failed-preview") assert.deepEqual(applied, []); + else assert.equal(applied.at(-1), theme); + assert.equal(disk, "light/dark"); + } + }); +} + +test("installed Pi reserves no /theme command and only string theme assignments persist", () => { + assert.equal(BUILTIN_SLASH_COMMANDS.some(command => command.name === "theme"), false); + const applied: Array = []; + let disk = "light/dark"; + const host = { + themeController: { + setThemeInstance: (theme: Theme) => { applied.push(theme); return { success: true }; }, + setThemeName: (name: string) => { applied.push(name); return { success: true }; }, + }, + settingsManager: { getTheme: () => disk, setTheme: (name: string) => { disk = name; } }, + }; + const create = Reflect.get(InteractiveMode.prototype, "createExtensionUIContext"); + const ui = create.call(host) as ExtensionContext["ui"]; + const preview = ui.getTheme("dark"); + assert.ok(preview); + ui.setTheme(preview); + assert.equal(applied.at(-1), preview); + assert.equal(disk, "light/dark"); + ui.setTheme("dark"); + assert.equal(disk, "dark"); +}); + +test("non-terminal modes do not start a selector or change themes", async () => { + for (const mode of ["rpc", "print", "json"]) { + let notices = 0; + await selectSlateTheme({ mode, ui: { notify() { notices++; } } } as unknown as ExtensionContext, + () => assert.fail("must not save")); + assert.equal(notices, 1); + } +}); diff --git a/pi-slate/tests/themes.test.ts b/pi-slate/tests/themes.test.ts index 44350fde..21f7e78c 100644 --- a/pi-slate/tests/themes.test.ts +++ b/pi-slate/tests/themes.test.ts @@ -87,6 +87,7 @@ test("bundled effort ramps start neutral and brighten toward each theme's border "tokyo-night": "#6785ca", "black-metal": "#8a8a8a", "catppuccin-mocha-default": "#9ea7da", "catppuccin-mocha-quiet": "#9da3bd", "catppuccin-mocha-mauve": "#af96d5", "catppuccin-mocha-pink": "#cca9ca", + "catppuccin-mocha-red": "#cb839e", "catppuccin-mocha-lavender": "#9ea7da", "catppuccin-mocha-sapphire": "#72adcd", "catppuccin-mocha-peach": "#cf9f87", "catppuccin-mocha-teal": "#88c0bd", }; diff --git a/pi-slate/themes/catppuccin-mocha-lavender.json b/pi-slate/themes/catppuccin-mocha-lavender.json new file mode 100644 index 00000000..841d0b8f --- /dev/null +++ b/pi-slate/themes/catppuccin-mocha-lavender.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "catppuccin-mocha-lavender", + "vars": { + "rosewater": "#f5e0dc", + "flamingo": "#f2cdcd", + "pink": "#f5c2e7", + "mauve": "#cba6f7", + "red": "#f38ba8", + "maroon": "#eba0ac", + "peach": "#fab387", + "yellow": "#f9e2af", + "green": "#a6e3a1", + "teal": "#94e2d5", + "sky": "#89dceb", + "sapphire": "#74c7ec", + "blue": "#89b4fa", + "lavender": "#b4befe", + "text": "#cdd6f4", + "subtext1": "#bac2de", + "subtext0": "#a6adc8", + "overlay2": "#9399b2", + "overlay1": "#7f849c", + "overlay0": "#6c7086", + "surface2": "#585b70", + "surface1": "#45475a", + "surface0": "#313244", + "base": "#1e1e2e", + "mantle": "#181825", + "crust": "#11111b", + "accent": "#b4befe", + "border": "#6c7086", + "heading": "#b4befe", + "panel": "#181825", + "surface": "#313244", + "thinkingOff": "#6c7086", + "thinkingMinimal": "#737892", + "thinkingLow": "#7a809e", + "thinkingMedium": "#8389ac", + "thinkingHigh": "#8c92bb", + "thinkingXhigh": "#959cca", + "thinkingMax": "#9ea7da" + }, + "colors": { + "accent": "accent", + "border": "border", + "borderAccent": "accent", + "borderMuted": "surface1", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "subtext0", + "dim": "overlay1", + "text": "text", + "thinkingText": "overlay2", + "selectedBg": "surface1", + "scrollbarTrack": "surface0", + "scrollbarThumb": "overlay1", + "searchMatchBg": "teal", + "searchMatchText": "text", + "userMessageBg": "surface", + "userMessageText": "text", + "customMessageBg": "panel", + "customMessageText": "text", + "customMessageLabel": "accent", + "toolPendingBg": "surface", + "toolSuccessBg": "panel", + "toolErrorBg": "crust", + "toolTitle": "text", + "toolOutput": "subtext0", + "mdHeading": "heading", + "mdLink": "accent", + "mdLinkUrl": "overlay1", + "mdCode": "accent", + "mdCodeBlock": "text", + "mdCodeBlockBorder": "overlay0", + "mdQuote": "overlay2", + "mdQuoteBorder": "overlay0", + "mdHr": "overlay0", + "mdListBullet": "accent", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "overlay2", + "syntaxComment": "overlay2", + "syntaxKeyword": "mauve", + "syntaxFunction": "blue", + "syntaxVariable": "text", + "syntaxString": "green", + "syntaxNumber": "peach", + "syntaxType": "yellow", + "syntaxOperator": "sky", + "syntaxPunctuation": "overlay2", + "thinkingOff": "thinkingOff", + "thinkingMinimal": "thinkingMinimal", + "thinkingLow": "thinkingLow", + "thinkingMedium": "thinkingMedium", + "thinkingHigh": "thinkingHigh", + "thinkingXhigh": "thinkingXhigh", + "thinkingMax": "thinkingMax", + "bashMode": "green" + }, + "export": { + "pageBg": "#1e1e2e", + "cardBg": "#181825", + "infoBg": "#11111b" + } +} diff --git a/pi-slate/themes/catppuccin-mocha-red.json b/pi-slate/themes/catppuccin-mocha-red.json new file mode 100644 index 00000000..dd28f474 --- /dev/null +++ b/pi-slate/themes/catppuccin-mocha-red.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "catppuccin-mocha-red", + "vars": { + "rosewater": "#f5e0dc", + "flamingo": "#f2cdcd", + "pink": "#f5c2e7", + "mauve": "#cba6f7", + "red": "#f38ba8", + "maroon": "#eba0ac", + "peach": "#fab387", + "yellow": "#f9e2af", + "green": "#a6e3a1", + "teal": "#94e2d5", + "sky": "#89dceb", + "sapphire": "#74c7ec", + "blue": "#89b4fa", + "lavender": "#b4befe", + "text": "#cdd6f4", + "subtext1": "#bac2de", + "subtext0": "#a6adc8", + "overlay2": "#9399b2", + "overlay1": "#7f849c", + "overlay0": "#6c7086", + "surface2": "#585b70", + "surface1": "#45475a", + "surface0": "#313244", + "base": "#1e1e2e", + "mantle": "#181825", + "crust": "#11111b", + "accent": "#f38ba8", + "border": "#6c7086", + "heading": "#f38ba8", + "panel": "#181825", + "surface": "#313244", + "thinkingOff": "#6c7086", + "thinkingMinimal": "#7a7389", + "thinkingLow": "#87758d", + "thinkingMedium": "#977991", + "thinkingHigh": "#a77c95", + "thinkingXhigh": "#b97f99", + "thinkingMax": "#cb839e" + }, + "colors": { + "accent": "accent", + "border": "border", + "borderAccent": "accent", + "borderMuted": "surface1", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "subtext0", + "dim": "overlay1", + "text": "text", + "thinkingText": "overlay2", + "selectedBg": "surface1", + "scrollbarTrack": "surface0", + "scrollbarThumb": "overlay1", + "searchMatchBg": "teal", + "searchMatchText": "text", + "userMessageBg": "surface", + "userMessageText": "text", + "customMessageBg": "panel", + "customMessageText": "text", + "customMessageLabel": "accent", + "toolPendingBg": "surface", + "toolSuccessBg": "panel", + "toolErrorBg": "crust", + "toolTitle": "text", + "toolOutput": "subtext0", + "mdHeading": "heading", + "mdLink": "accent", + "mdLinkUrl": "overlay1", + "mdCode": "accent", + "mdCodeBlock": "text", + "mdCodeBlockBorder": "overlay0", + "mdQuote": "overlay2", + "mdQuoteBorder": "overlay0", + "mdHr": "overlay0", + "mdListBullet": "accent", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "overlay2", + "syntaxComment": "overlay2", + "syntaxKeyword": "mauve", + "syntaxFunction": "blue", + "syntaxVariable": "text", + "syntaxString": "green", + "syntaxNumber": "peach", + "syntaxType": "yellow", + "syntaxOperator": "sky", + "syntaxPunctuation": "overlay2", + "thinkingOff": "thinkingOff", + "thinkingMinimal": "thinkingMinimal", + "thinkingLow": "thinkingLow", + "thinkingMedium": "thinkingMedium", + "thinkingHigh": "thinkingHigh", + "thinkingXhigh": "thinkingXhigh", + "thinkingMax": "thinkingMax", + "bashMode": "green" + }, + "export": { + "pageBg": "#1e1e2e", + "cardBg": "#181825", + "infoBg": "#11111b" + } +} From ee367cfc71844b7435c383a5993484cddd6980fa Mon Sep 17 00:00:00 2001 From: GaganSD Date: Wed, 28 Oct 2026 02:59:04 +0530 Subject: [PATCH 05/14] Limit Slate picker to Pink, Red, and Lavender --- pi-slate/README.md | 2 +- .../extensions/pi-slate/theme-selector.ts | 7 ++- pi-slate/tests/theme-selector.test.ts | 43 ++++++++++++++++--- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/pi-slate/README.md b/pi-slate/README.md index e2edfd4c..e7160106 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -69,7 +69,7 @@ Other extensions can publish detached terminals or jobs to the sidebar's task sh Open `/theme slate` for a centered theme selector with live preview: - **↑ / ↓** selects and immediately applies a theme; **← / →** changes its labeled palette variant. -- Catppuccin variants include **Pink**, **Red** (`#f38ba8`), and **Lavender** (`#b4befe`), alongside the existing styles. Each theme remembers its variant while the selector is open. +- The Catppuccin picker cycles through **Pink** → **Red** (`#f38ba8`) → **Lavender** (`#b4befe`). Each theme remembers its variant while the selector is open. Existing styles remain available through legacy commands; opening from one preserves it in a separate current-palette row. - **Enter** confirms and saves; **Escape** (or **Ctrl+C**) restores the original visible theme without changing saved theme settings or Slate configuration. The selector requires interactive terminal mode. Pi 1.4 has no built-in `/theme` command; core theme selection remains in `/settings`. After previewing and cancelling, Pi's public theme-instance API restores the original palette but pauses automatic terminal-appearance following (including `system` and light/dark pairs) until `/reload`. Merely opening and cancelling without previewing does not affect appearance following. diff --git a/pi-slate/extensions/pi-slate/theme-selector.ts b/pi-slate/extensions/pi-slate/theme-selector.ts index 8a77083e..d73725c5 100644 --- a/pi-slate/extensions/pi-slate/theme-selector.ts +++ b/pi-slate/extensions/pi-slate/theme-selector.ts @@ -1,16 +1,19 @@ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { matchesKey, parseColor, truncateToWidth, visibleWidth, type Component, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui"; -import { buildCatppuccinTheme, catppuccinThemeName, parseCatppuccinTheme, STYLES, STYLE_LABELS } from "./catppuccin.ts"; +import { buildCatppuccinTheme, catppuccinThemeName, parseCatppuccinTheme, STYLE_LABELS } from "./catppuccin.ts"; export type ThemeVariant = { name: string; label: string; color?: string }; export type ThemeChoice = { label: string; variants: ThemeVariant[] }; +// Keep the picker cycle independent of styles retained for legacy commands. +const PICKER_STYLES = ["pink", "red", "lavender"] as const; + export function slateThemeChoices(initial?: string): ThemeChoice[] { const choices: ThemeChoice[] = [ { label: "System default", variants: [{ name: "system", label: "Terminal palette" }] }, { label: "Tokyo Night", variants: [{ name: "tokyo-night", label: "Default", color: "#7aa2f7" }] }, { label: "Black Metal", variants: [{ name: "black-metal", label: "Default", color: "#dd9999" }] }, - { label: "catppuccin-mocha", variants: STYLES.map(style => ({ + { label: "catppuccin-mocha", variants: PICKER_STYLES.map(style => ({ name: catppuccinThemeName("mocha", style), label: style === "pink" ? "Pink" : STYLE_LABELS[style], color: buildCatppuccinTheme("mocha", style).vars.accent, })) }, diff --git a/pi-slate/tests/theme-selector.test.ts b/pi-slate/tests/theme-selector.test.ts index f6efec27..2472d721 100644 --- a/pi-slate/tests/theme-selector.test.ts +++ b/pi-slate/tests/theme-selector.test.ts @@ -3,6 +3,7 @@ import test from "node:test"; import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { visibleWidth, type TUI } from "@earendil-works/pi-tui"; import { KeybindingsManager } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/keybindings.js"; +import { STYLES, catppuccinThemeName } from "../extensions/pi-slate/catppuccin.ts"; import { selectSlateTheme, slateThemeChoices, ThemeSelector } from "../extensions/pi-slate/theme-selector.ts"; import { InteractiveMode } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js"; import { BUILTIN_SLASH_COMMANDS } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js"; @@ -17,7 +18,7 @@ function fixture(initial = "catppuccin-mocha-pink") { const done: Array = []; const terminal = { rows: 40, columns: 80 }; let renders = 0; - const selector = new ThemeSelector(slateThemeChoices(), initial, { terminal: terminal as TUI["terminal"], requestRender: () => { renders++; } }, + const selector = new ThemeSelector(slateThemeChoices(initial), initial, { terminal: terminal as TUI["terminal"], requestRender: () => { renders++; } }, () => theme, keys, name => { previews.push(name); return undefined; }, value => done.push(value)); return { selector, previews, done, terminal, renders: () => renders }; } @@ -72,13 +73,43 @@ test("an external original theme stays visibly selected until navigation", () => assert.match(selector.render(58).join("\n"), /› external \(current\)/); }); -test("requested palettes use the exact accents", () => { - const variants = slateThemeChoices().at(-1)!.variants; - assert.equal(variants.find(item => item.label === "Pink")!.color, "#f5c2e7"); - assert.equal(variants.find(item => item.label === "Red")!.color, "#f38ba8"); - assert.equal(variants.find(item => item.label === "Lavender")!.color, "#b4befe"); +test("picker palettes are exactly Pink, Red, Lavender in order with exact accents", () => { + assert.deepEqual(slateThemeChoices().at(-1)!.variants, [ + { name: "catppuccin-mocha-pink", label: "Pink", color: "#f5c2e7" }, + { name: "catppuccin-mocha-red", label: "Red", color: "#f38ba8" }, + { name: "catppuccin-mocha-lavender", label: "Lavender", color: "#b4befe" }, + ]); }); +test("three-palette cycle wraps in both directions without legacy styles", () => { + const f = fixture(); + for (const key of [right, right, right, left, left, left]) f.selector.handleInput(key); + assert.deepEqual(f.previews, [ + "catppuccin-mocha-red", "catppuccin-mocha-lavender", "catppuccin-mocha-pink", + "catppuccin-mocha-lavender", "catppuccin-mocha-red", "catppuccin-mocha-pink", + ]); +}); + +for (const style of STYLES.filter(style => !["pink", "red", "lavender"].includes(style))) { + test(`legacy ${style} stays current outside the normal picker cycle`, () => { + const initial = catppuccinThemeName("mocha", style); + const f = fixture(initial); + assert.deepEqual(f.previews, []); + assert.match(f.selector.render(58).join("\n"), new RegExp(`› ${initial} \\(current\\)`)); + f.selector.handleInput(right); + f.selector.handleInput(left); + assert.deepEqual(f.previews, [], "current row does not cycle"); + f.selector.handleInput(up); + assert.equal(f.previews.at(-1), "catppuccin-mocha-pink"); + for (let i = 0; i < 3; i++) f.selector.handleInput(right); + assert.equal(f.previews.at(-1), "catppuccin-mocha-pink"); + f.selector.handleInput(down); + assert.equal(f.previews.at(-1), initial); + f.selector.handleInput("\r"); + assert.deepEqual(f.done, [initial]); + }); +} + for (const action of ["save", "cancel", "throw", "failed-save", "failed-preview"] as const) { test(`selector transaction: ${action}`, async () => { const applied: Array = []; From 09de5b3b6bc3a7ae34e2fdd1e473128020004d12 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Wed, 28 Oct 2026 03:15:23 +0530 Subject: [PATCH 06/14] Restore Slate picker themes through host-loaded snapshots --- .../extensions/pi-slate/theme-selector.ts | 13 +++- pi-slate/tests/surfaces-install.test.ts | 1 + pi-slate/tests/theme-selector.test.ts | 61 ++++++++++++++++--- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/pi-slate/extensions/pi-slate/theme-selector.ts b/pi-slate/extensions/pi-slate/theme-selector.ts index d73725c5..e48c1ec8 100644 --- a/pi-slate/extensions/pi-slate/theme-selector.ts +++ b/pi-slate/extensions/pi-slate/theme-selector.ts @@ -116,12 +116,21 @@ export class ThemeSelector implements Component { export async function selectSlateTheme(ctx: ExtensionContext, save: (name: string) => boolean): Promise { if (ctx.mode !== "tui") { ctx.ui.notify("/theme slate requires interactive terminal mode", "error"); return; } - const original = ctx.ui.theme; + // ui.theme is Pi's live styling proxy, not a concrete Theme: its name changes + // during previews and setTheme(proxy) can take the persistent string path. + // Resolve through this host's API (not an imported Theme constructor, whose + // identity can differ across Pi's bundled host and extension module loaders). + const originalName = ctx.ui.theme.name; + const original = originalName ? ctx.ui.getTheme(originalName) : undefined; + if (!original) { + ctx.ui.notify("Could not snapshot the current theme. Run /reload before previewing.", "error"); + return; + } let confirmed = false; let previewed = false; try { const selected = await ctx.ui.custom((tui, _theme, keys, done) => new ThemeSelector( - slateThemeChoices(original.name), original.name, tui, () => ctx.ui.theme, keys, + slateThemeChoices(originalName), originalName, tui, () => ctx.ui.theme, keys, name => { // Pi persists string setTheme calls. Theme instances preview without touching settings. const theme = ctx.ui.getTheme(name); diff --git a/pi-slate/tests/surfaces-install.test.ts b/pi-slate/tests/surfaces-install.test.ts index 514dc86d..b2182494 100644 --- a/pi-slate/tests/surfaces-install.test.ts +++ b/pi-slate/tests/surfaces-install.test.ts @@ -91,6 +91,7 @@ async function fixture(t: TestContext, text: string | undefined, mode = "tui", c let header: Component | undefined; const ui = { theme, + getTheme: (name: string) => ({ ...theme, name }), notify: (message: string) => notices.push(message), setHeader: (factory: ((tui: TUI, theme: Theme) => Component) | undefined) => { assert.ok(factory); calls.push("header"); header = factory(tui, theme); }, setFooter: (factory: (() => Component) | undefined) => { assert.ok(factory); calls.push("footer"); assert.deepEqual(factory().render(80), []); }, diff --git a/pi-slate/tests/theme-selector.test.ts b/pi-slate/tests/theme-selector.test.ts index 2472d721..0b7c98d9 100644 --- a/pi-slate/tests/theme-selector.test.ts +++ b/pi-slate/tests/theme-selector.test.ts @@ -6,6 +6,7 @@ import { KeybindingsManager } from "../node_modules/@earendil-works/pi-coding-ag import { STYLES, catppuccinThemeName } from "../extensions/pi-slate/catppuccin.ts"; import { selectSlateTheme, slateThemeChoices, ThemeSelector } from "../extensions/pi-slate/theme-selector.ts"; import { InteractiveMode } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js"; +import { initTheme, setThemeInstance } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.js"; import { BUILTIN_SLASH_COMMANDS } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js"; const theme = { name: "external", fg: (_: string, text: string) => text, bold: (text: string) => text, @@ -110,19 +111,25 @@ for (const style of STYLES.filter(style => !["pink", "red", "lavender"].includes }); } -for (const action of ["save", "cancel", "throw", "failed-save", "failed-preview"] as const) { +for (const action of ["save", "cancel", "ctrl-c", "throw", "failed-save", "failed-preview"] as const) { test(`selector transaction: ${action}`, async () => { const applied: Array = []; const saved: string[] = []; - let current = theme; + // Host instances deliberately have a different constructor from extension + // imports; the live styling proxy is neither a host instance nor a snapshot. + class HostTheme { name: string; constructor(name: string) { this.name = name; } } + const original = new HostTheme(theme.name!); + let current = original; + const live = new Proxy({}, { get: (_target, key) => Reflect.get(current, key) }); let disk = "light/dark"; const ctx = { mode: "tui", ui: { - get theme() { return current; }, - getTheme: (name: string) => action === "failed-preview" ? undefined : { ...theme, name } as Theme, + get theme() { return live; }, + getTheme: (name: string) => name === original.name ? original + : action === "failed-preview" ? undefined : new HostTheme(name), setTheme: (value: Theme | string) => { applied.push(value); - if (typeof value === "string") disk = value; - else current = value; + if (value instanceof HostTheme) current = value; + else { disk = String(value); assert.fail("Only host-created instances may preview or restore"); } return { success: true }; }, notify() {}, @@ -132,14 +139,15 @@ for (const action of ["save", "cancel", "throw", "failed-save", "failed-preview" const component = await factory({ terminal: { rows: 40 }, requestRender() {} } as TUI, theme, keys, value => { result = value; }); component.handleInput!(down); assert.equal(disk, "light/dark", "previews never write settings, including auto-theme pairs"); + if (action !== "failed-preview") assert.equal(Reflect.get(live, "name"), "system", "captured proxy follows preview"); if (action === "throw") throw new Error("closed"); - component.handleInput!(action === "cancel" ? "\x1b" : "\r"); + component.handleInput!(action === "cancel" ? "\x1b" : action === "ctrl-c" ? "\x03" : "\r"); return result; }, } } as unknown as ExtensionContext; const run = () => selectSlateTheme(ctx, name => { if (action === "failed-save") return false; - saved.push(name); ctx.ui.setTheme(name); return true; + saved.push(name); disk = name; return true; }); if (action === "throw") await assert.rejects(run, /closed/); else await run(); if (action === "save") { @@ -147,7 +155,7 @@ for (const action of ["save", "cancel", "throw", "failed-save", "failed-preview" } else { assert.deepEqual(saved, []); if (action === "failed-preview") assert.deepEqual(applied, []); - else assert.equal(applied.at(-1), theme); + else { assert.equal(applied.at(-1), original); assert.equal(current, original); } assert.equal(disk, "light/dark"); } }); @@ -175,6 +183,41 @@ test("installed Pi reserves no /theme command and only string theme assignments assert.equal(disk, "dark"); }); +test("real Pi UI proxy is not restorable, but host-loaded snapshots restore after exceptions", async () => { + initTheme("dark"); + const create = Reflect.get(InteractiveMode.prototype, "createExtensionUIContext"); + const host = { + themeController: { + setThemeInstance: (value: Theme) => { setThemeInstance(value); return { success: true }; }, + setThemeName: () => assert.fail("proxy or foreign instances must not reach the name/persistence path"), + }, + }; + const ui = create.call(host) as ExtensionContext["ui"]; + const live = ui.theme; + const snapshot = ui.getTheme("dark")!; + assert.equal(live instanceof snapshot.constructor, false, "Pi exposes a plain-target live proxy"); + ui.custom = async factory => { + const component = await factory({ terminal: { rows: 40 }, requestRender() {} } as TUI, live, keys, () => {}); + component.handleInput!(down); // external-current dark -> system + assert.equal(live.name, "system", "even a previously captured name property now follows the preview"); + throw new Error("UI failed after preview"); + }; + await assert.rejects(() => selectSlateTheme({ mode: "tui", ui } as ExtensionContext, + () => assert.fail("must not save")), /UI failed after preview/); + assert.equal(ui.theme.name, "dark"); + assert.equal(ui.theme.fg("accent", "restored"), snapshot.fg("accent", "restored")); +}); + +test("an unavailable original snapshot refuses previews without writing settings", async () => { + const notices: string[] = []; + await selectSlateTheme({ mode: "tui", ui: { + theme, getTheme: () => undefined, notify: (message: string) => notices.push(message), + custom: () => assert.fail("cannot safely preview without a restoration snapshot"), + setTheme: () => assert.fail("must not change themes"), + } } as unknown as ExtensionContext, () => assert.fail("must not save")); + assert.match(notices[0]!, /Could not snapshot/); +}); + test("non-terminal modes do not start a selector or change themes", async () => { for (const mode of ["rpc", "print", "json"]) { let notices = 0; From 2b40b311f70505143b7b0e3a872178f3ae26ea5a Mon Sep 17 00:00:00 2001 From: GaganSD Date: Wed, 28 Oct 2026 03:46:27 +0530 Subject: [PATCH 07/14] Make /theme direct and stabilize the responsive theme picker --- pi-slate/CHANGELOG.md | 2 +- pi-slate/README.md | 9 +- pi-slate/extensions/pi-slate/index.ts | 6 +- .../extensions/pi-slate/theme-selector.ts | 94 ++++++++++----- pi-slate/tests/surfaces-install.test.ts | 9 +- pi-slate/tests/theme-selector.test.ts | 107 +++++++++++++++++- 6 files changed, 178 insertions(+), 49 deletions(-) diff --git a/pi-slate/CHANGELOG.md b/pi-slate/CHANGELOG.md index cc291b1e..dff6292f 100644 --- a/pi-slate/CHANGELOG.md +++ b/pi-slate/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- `/theme slate` opens a centered live-preview selector: up/down changes themes, left/right changes labeled palette variants, Enter saves, and Escape restores the original visible theme without writing settings. `/slate theme [name]` remains supported; Pi's `/settings` is unchanged. +- `/theme` opens a larger centered live-preview selector with fixed geometry, scrolling theme and color-swatch palette lists, selection/overflow cues, and responsive two-column, stacked, or tiny-screen layouts. Up/down changes themes, left/right changes palettes, Enter saves, and Escape/Ctrl+C restores the original visible theme without writing settings. `/theme slate` and `/slate theme [name]` remain supported; Pi's `/settings` is unchanged. - Catppuccin Mocha Red (`#f38ba8`) and Lavender (`#b4befe`) variants alongside existing Pink; variant choices survive navigation within the selector. - On Pi 1.4, cancelling after preview restores the original palette but automatic terminal-appearance following resumes only after `/reload` (public theme-instance API limitation). Saved theme and light/dark settings are untouched. diff --git a/pi-slate/README.md b/pi-slate/README.md index e7160106..fd3a0735 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -66,9 +66,10 @@ Other extensions can publish detached terminals or jobs to the sidebar's task sh ### Themes -Open `/theme slate` for a centered theme selector with live preview: +Open `/theme` for a larger, centered theme selector with live preview (`/theme slate` remains an alias): -- **↑ / ↓** selects and immediately applies a theme; **← / →** changes its labeled palette variant. +- **↑ / ↓** selects and immediately applies a theme; **← / →** changes its palette. Dedicated theme and color-swatch lists show selection, position, and overflow counts; both scroll without changing the dialog's shape. +- Wide terminals use two columns; narrow terminals stack the lists, and very small screens keep the selected identity and essential controls. The dialog resizes to fit the terminal, not the selected theme or palette. - The Catppuccin picker cycles through **Pink** → **Red** (`#f38ba8`) → **Lavender** (`#b4befe`). Each theme remembers its variant while the selector is open. Existing styles remain available through legacy commands; opening from one preserves it in a separate current-palette row. - **Enter** confirms and saves; **Escape** (or **Ctrl+C**) restores the original visible theme without changing saved theme settings or Slate configuration. @@ -89,7 +90,7 @@ Direct selection with `/slate theme ` remains supported: | `teal` | 🟩 | | `default` | Pi's system theme | -Black Metal is an optional bundled theme, not an install default: pick it with `/slate theme black-metal` or `/theme slate` (`/slate theme` also opens the menu). Confirmed selections are saved and also appear in `/settings`. +Black Metal is an optional bundled theme, not an install default: pick it with `/slate theme black-metal` or `/theme` (`/slate theme` also opens the menu). Confirmed selections are saved and also appear in `/settings`. With `/slate effort-border on`, bundled themes use a restrained effort ramp from their neutral border toward their border accent: blue for Tokyo Night, the selected Catppuccin style, cool grays for Quiet, and grayscale for Black Metal. Higher effort increases intensity instead of switching to unrelated hues; the effort label remains the precise cue. The sidebar stays neutral, bash mode keeps its separate color, and external themes retain their own effort colors. These are native theme tokens, so Pi's stock editor also uses the ramp when a bundled Slate theme is selected. Effort-colored Slate frames remain off by default. @@ -135,7 +136,7 @@ These work when `editor` is selected. No terminal configuration. | Message length | `/slate message-length [default\|all\|]` | How many chat messages stay on screen. `default` is 100. | | Density | `/slate density [comfortable\|compact]` | Comfortable shows a › prompt in the composer; compact is tighter. | | Composer metadata | `/slate footer [standard\|minimal]` | Legacy command alias: standard shows model and thinking on the composer; minimal hides them. Not footer-slot ownership. | -| Theme | `/theme slate` or `/slate theme [default\|tokyo-night\|black-metal\|quiet\|mauve\|catppuccin-mocha-pink\|red\|lavender\|sapphire\|peach\|teal]` | Default restores Pi's system theme. Tokyo Night and Black Metal are explicit; other styles select Catppuccin; `/slate style` retains Catppuccin aliases. | +| Theme | `/theme` or `/slate theme [default\|tokyo-night\|black-metal\|quiet\|mauve\|catppuccin-mocha-pink\|red\|lavender\|sapphire\|peach\|teal]` | Default restores Pi's system theme. Tokyo Night and Black Metal are explicit; other styles select Catppuccin; `/slate style` retains Catppuccin aliases. | | Bugs | `/slate bug [file\|open]` | Copy a bug report, or open the npm package page. | ## Independent Surfaces diff --git a/pi-slate/extensions/pi-slate/index.ts b/pi-slate/extensions/pi-slate/index.ts index d76a4a32..0609a941 100644 --- a/pi-slate/extensions/pi-slate/index.ts +++ b/pi-slate/extensions/pi-slate/index.ts @@ -1103,12 +1103,10 @@ export default function piSlate(pi: ExtensionAPI): void { }; // Pi 1.4 has no built-in /theme command; core theme selection lives in /settings. - // Keep this namespace narrow rather than replacing core settings behavior. pi.registerCommand("theme", { - description: "Open the Slate theme selector with /theme slate", - getArgumentCompletions: (prefix) => "slate".startsWith(prefix.trim()) ? [{ value: "slate", label: "slate" }] : null, + description: "Choose a theme with live preview", handler: async (args, ctx) => { - if (args.trim() !== "slate") { ctx.ui.notify("Usage: /theme slate (Pi themes are also available in /settings)", "info"); return; } + if (args.trim() && args.trim() !== "slate") { ctx.ui.notify("Usage: /theme (Pi themes are also available in /settings)", "info"); return; } await pickTheme(ctx); }, }); diff --git a/pi-slate/extensions/pi-slate/theme-selector.ts b/pi-slate/extensions/pi-slate/theme-selector.ts index e48c1ec8..2ca7c3a0 100644 --- a/pi-slate/extensions/pi-slate/theme-selector.ts +++ b/pi-slate/extensions/pi-slate/theme-selector.ts @@ -1,5 +1,5 @@ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; -import { matchesKey, parseColor, truncateToWidth, visibleWidth, type Component, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui"; +import { matchesKey, parseColor, truncateToWidth, type Component, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui"; import { buildCatppuccinTheme, catppuccinThemeName, parseCatppuccinTheme, STYLE_LABELS } from "./catppuccin.ts"; export type ThemeVariant = { name: string; label: string; color?: string }; @@ -56,7 +56,7 @@ export class ThemeSelector implements Component { } } - private current(): ThemeVariant { return this.choices[this.selected]!.variants[this.variants[this.selected]!]!; } + private current(): ThemeVariant | undefined { return this.choices[this.selected]?.variants[this.variants[this.selected] ?? 0]; } handleInput(data: string): void { if (this.closed) return; @@ -64,50 +64,82 @@ export class ThemeSelector implements Component { this.closed = true; this.done(undefined); return; } if (this.keys.matches(data, "tui.select.confirm")) { - if (!this.error) { this.closed = true; this.done(this.current().name); } + const current = this.current(); + if (!this.error && current) { this.closed = true; this.done(current.name); } return; } + if (!this.choices.length) return; if (this.keys.matches(data, "tui.select.up")) this.selected = (this.selected + this.choices.length - 1) % this.choices.length; else if (this.keys.matches(data, "tui.select.down")) this.selected = (this.selected + 1) % this.choices.length; else if (matchesKey(data, "left") || matchesKey(data, "right")) { const count = this.choices[this.selected]!.variants.length; - if (count === 1) return; + if (count <= 1) return; this.variants[this.selected] = (this.variants[this.selected]! + (matchesKey(data, "right") ? 1 : count - 1)) % count; } else return; - this.error = this.preview(this.current().name) ?? ""; + const current = this.current(); + this.error = current ? this.preview(current.name) ?? "" : "No palettes available"; this.tui.requestRender(); } render(width: number): string[] { const th = this.theme(); const current = this.current(); - const choice = this.choices[this.selected]!; - const inner = Math.max(0, width - 4); - const swatch = (variant: ThemeVariant) => variant.color - ? th.style("●", { fg: parseColor(variant.color) }) : th.fg("muted", "●"); - const lines = [th.bold("Slate themes"), ...this.choices.map((item, index) => - index === this.selected ? th.fg("accent", `› ${item.label}`) : th.fg("muted", ` ${item.label}`)), - th.fg("accent", `${swatch(current)} ${current.label}`)]; - if (choice.variants.length > 1) { - // Wrap labeled swatches instead of clipping the active variant off-screen. - let row = ""; - for (const variant of choice.variants) { - const label = `${swatch(variant)} ${variant.name === current.name ? `[${variant.label}]` : variant.label} `; - if (row && visibleWidth(row + label) > inner) { lines.push(row); row = ""; } - row += label; - } - if (row) lines.push(row); + const choice = this.choices[this.selected]; + const height = Math.min(22, Math.max(1, this.tui.terminal.rows - 2)); + const fit = (text: string, columns: number) => truncateToWidth(text, Math.max(0, columns), "…", true); + const status = this.error || (!choice ? "No themes available" : !current ? "No palettes available" : ""); + // Tiny screens trade the frame and list for identity and controls. Geometry + // still depends only on terminal size, never on names, counts, or errors. + if (height < 13 || width < 24) { + const lines = [ + th.fg("accent", choice?.label ?? "No themes available"), + current ? `${(this.variants[this.selected] ?? 0) + 1}/${choice!.variants.length} · ${current.label}` : "No palettes available", + th.fg("dim", width < 28 ? "↵ save Esc undo" : "Enter save · Esc restore"), + status ? th.fg("error", status) : th.fg("dim", "↑↓ theme ←→ color"), + status ? th.fg("dim", "↑↓ theme ←→ color") : "", + ]; + return Array.from({ length: height }, (_, i) => fit(lines[i] ?? "", width)); } - if (this.error) lines.push(th.fg("error", this.error)); - lines.push(th.fg("dim", "↑↓ theme · ←→ variant"), th.fg("dim", "Enter save · Esc restore")); - const available = Math.max(1, this.tui.terminal.rows - 2); - // Very short terminals retain selection and key hints rather than cutting off controls. - const content = lines.length + 2 <= available ? lines - : [th.bold("Slate themes"), th.fg("accent", `${choice.label} · ${current.label}`), ...lines.slice(-2)]; - if (width < 4) return content.slice(0, available).map(line => truncateToWidth(line, width, "")); + const inner = width - 4; + const bodyHeight = height - 7; const border = (text: string) => th.fg("border", text); + const selectedRow = (text: string, selected: boolean, columns: number) => { + const row = fit(`${selected ? "›" : " "} ${text}`, columns); + return selected ? th.style(row, { fg: "accent", bg: "selectedBg", bold: true }) : row; + }; + // Both viewports are bounded and center their selected row, including on + // resize. Headers carry position and hidden-row counts instead of wrapping. + const viewport = (title: string, count: number, selected: number, columns: number, rows: number, + row: (index: number) => string): string[] => { + const slots = Math.max(1, rows - 1); + const start = Math.max(0, Math.min(selected - Math.floor(slots / 2), count - slots)); + const end = Math.min(count, start + slots); + const overflow = `${start ? ` ↑${start}` : ""}${end < count ? ` ↓${count - end}` : ""}`; + return [th.fg("muted", fit(`${title} ${count ? selected + 1 : 0}/${count}${overflow}`, columns)), + ...Array.from({ length: rows - 1 }, (_, i) => start + i < end ? fit(row(start + i), columns) : fit("", columns))]; + }; + const themeRows = (columns: number, rows: number) => viewport("Themes", this.choices.length, this.selected, columns, rows, + i => selectedRow(this.choices[i]!.label, i === this.selected, columns)); + const paletteRows = (columns: number, rows: number) => viewport("Palettes", choice?.variants.length ?? 0, + this.variants[this.selected] ?? 0, columns, rows, i => { + const variant = choice!.variants[i]!; + const swatch = variant.color ? th.style("████", { fg: parseColor(variant.color) }) : th.fg("muted", "▒▒▒▒"); + const label = `${swatch} ${variant.label}${columns >= 34 && variant.color ? ` ${th.fg("dim", variant.color)}` : ""}`; + return selectedRow(label, i === this.variants[this.selected], columns); + }); + let body: string[]; + if (width >= 68) { + const left = Math.floor((inner - 3) * 0.48), right = inner - 3 - left; + const themes = themeRows(left, bodyHeight), palettes = paletteRows(right, bodyHeight); + body = themes.map((line, i) => line + border(" │ ") + palettes[i]!); + } else { + const top = Math.ceil(bodyHeight / 2); + body = [...themeRows(inner, top), ...paletteRows(inner, bodyHeight - top)]; + } + const content = [th.bold("Slate themes"), "", ...body, th.fg("error", status), + th.fg("dim", "↑↓ theme ←→ palette"), th.fg("dim", width < 44 ? "Enter save/Esc undo" : "Enter save · Esc restore · Ctrl+C cancel")]; return [border(`╭${"─".repeat(width - 2)}╮`), - ...content.slice(0, Math.max(0, available - 2)).map(line => border("│ ") + truncateToWidth(line, inner, "…", true) + border(" │")), + ...content.map(line => border("│ ") + fit(line, inner) + border(" │")), border(`╰${"─".repeat(width - 2)}╯`)]; } @@ -115,7 +147,7 @@ export class ThemeSelector implements Component { } export async function selectSlateTheme(ctx: ExtensionContext, save: (name: string) => boolean): Promise { - if (ctx.mode !== "tui") { ctx.ui.notify("/theme slate requires interactive terminal mode", "error"); return; } + if (ctx.mode !== "tui") { ctx.ui.notify("/theme requires interactive terminal mode", "error"); return; } // ui.theme is Pi's live styling proxy, not a concrete Theme: its name changes // during previews and setTheme(proxy) can take the persistent string path. // Resolve through this host's API (not an imported Theme constructor, whose @@ -139,7 +171,7 @@ export async function selectSlateTheme(ctx: ExtensionContext, save: (name: strin const result = ctx.ui.setTheme(theme); return result.success ? undefined : result.error ?? `Could not preview ${name}`; }, done, - ), { overlay: true, overlayOptions: { anchor: "center", width: 58, maxHeight: "100%" } }); + ), { overlay: true, overlayOptions: { anchor: "center", width: 88, maxHeight: "100%", margin: 1 } }); if (selected) confirmed = save(selected); } finally { if (!confirmed && previewed) { diff --git a/pi-slate/tests/surfaces-install.test.ts b/pi-slate/tests/surfaces-install.test.ts index b2182494..069bcaf0 100644 --- a/pi-slate/tests/surfaces-install.test.ts +++ b/pi-slate/tests/surfaces-install.test.ts @@ -393,18 +393,19 @@ test("system default and explicit Tokyo Night preserve unrelated native settings assert.deepEqual(saved.packages, ["keep-this-package"]); }); -test("/theme slate routes only its namespace and preserves legacy theme commands", async t => { +test("/theme opens directly, retains aliases, and rejects unknown arguments", async t => { const f = await fixture(t, JSON.stringify({ surfaces: [] })); const menus: string[] = []; t.mock.method(f.ctx.ui, "custom", async () => { menus.push("Theme"); return undefined; }); t.mock.method(f.ctx.ui, "setTheme", () => ({ success: true })); + await f.commands.get("theme")!.handler("", f.ctx); + await f.commands.get("theme")!.handler(" ", f.ctx); await f.commands.get("theme")!.handler("slate", f.ctx); await f.commands.get("slate")!.handler("theme", f.ctx); - assert.deepEqual(menus, ["Theme", "Theme"]); - await f.commands.get("theme")!.handler("", f.ctx); + assert.deepEqual(menus, ["Theme", "Theme", "Theme", "Theme"]); await f.commands.get("theme")!.handler("dark", f.ctx); await f.commands.get("theme")!.handler("slate extra", f.ctx); - assert.deepEqual(menus, ["Theme", "Theme"], "unrelated arguments never open the Slate picker"); + assert.deepEqual(menus, ["Theme", "Theme", "Theme", "Theme"], "unrelated arguments never open the Slate picker"); assert.equal(f.commands.has("settings"), false, "core settings remain untouched"); }); diff --git a/pi-slate/tests/theme-selector.test.ts b/pi-slate/tests/theme-selector.test.ts index 0b7c98d9..bfc922d4 100644 --- a/pi-slate/tests/theme-selector.test.ts +++ b/pi-slate/tests/theme-selector.test.ts @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; -import { visibleWidth, type TUI } from "@earendil-works/pi-tui"; +import { parseColor, visibleWidth, type TUI } from "@earendil-works/pi-tui"; import { KeybindingsManager } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/keybindings.js"; import { STYLES, catppuccinThemeName } from "../extensions/pi-slate/catppuccin.ts"; -import { selectSlateTheme, slateThemeChoices, ThemeSelector } from "../extensions/pi-slate/theme-selector.ts"; +import { selectSlateTheme, slateThemeChoices, ThemeSelector, type ThemeChoice } from "../extensions/pi-slate/theme-selector.ts"; import { InteractiveMode } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js"; import { initTheme, setThemeInstance } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/theme/theme.js"; import { BUILTIN_SLASH_COMMANDS } from "../node_modules/@earendil-works/pi-coding-agent/dist/core/slash-commands.js"; @@ -29,7 +29,7 @@ test("theme navigation previews immediately and remembers each theme's variant", assert.deepEqual(f.previews, [], "opening preserves original theme"); f.selector.handleInput(right); assert.deepEqual(f.previews, ["catppuccin-mocha-red"]); - assert.match(f.selector.render(58).join("\n"), /\[Red\]/); + assert.match(f.selector.render(58).join("\n"), /› ████ Red/); f.selector.handleInput(right); assert.equal(f.previews.at(-1), "catppuccin-mocha-lavender"); f.selector.handleInput(up); @@ -59,8 +59,10 @@ test("selector wraps, cancels, and keeps labels and controls bounded after resiz } } f.terminal.rows = 8; - assert.match(f.selector.render(58).join("\n"), /catppuccin-mocha · Pink/); + assert.match(f.selector.render(58).join("\n"), /catppuccin-mocha\s*\n1\/3 · Pink/); assert.match(f.selector.render(58).join("\n"), /Esc restore/); + assert.match(f.selector.render(18).join("\n"), /↵ save Esc undo/); + assert.match(f.selector.render(18).join("\n"), /1\/3 · Pink/); f.selector.handleInput("\x1b"); assert.deepEqual(f.done, [undefined]); }); @@ -134,7 +136,7 @@ for (const action of ["save", "cancel", "ctrl-c", "throw", "failed-save", "faile }, notify() {}, custom: async (factory: Parameters[0], options: unknown) => { - assert.deepEqual(options, { overlay: true, overlayOptions: { anchor: "center", width: 58, maxHeight: "100%" } }); + assert.deepEqual(options, { overlay: true, overlayOptions: { anchor: "center", width: 88, maxHeight: "100%", margin: 1 } }); let result: unknown; const component = await factory({ terminal: { rows: 40 }, requestRender() {} } as TUI, theme, keys, value => { result = value; }); component.handleInput!(down); @@ -218,6 +220,101 @@ test("an unavailable original snapshot refuses previews without writing settings assert.match(notices[0]!, /Could not snapshot/); }); +test("geometry is fixed across theme, palette, error, and empty states after every resize", () => { + const terminal = { rows: 40 }; + const choices: ThemeChoice[] = [ + { label: "界🌙é".repeat(40), variants: [] }, + { label: "Single", variants: [{ name: "single", label: "Default" }] }, + { label: "Many", variants: Array.from({ length: 100 }, (_, i) => ({ name: `v${i}`, label: `界🌙é${i}`.repeat(30), color: "#f38ba8" })) }, + ]; + const finished: Array = []; + const selector = new ThemeSelector(choices, "single", { terminal: terminal as TUI["terminal"], requestRender() {} }, () => theme, keys, + name => name.startsWith("v") ? "Could not load 界".repeat(80) : undefined, name => finished.push(name)); + const empty = new ThemeSelector([], undefined, { terminal: terminal as TUI["terminal"], requestRender() {} }, () => theme, keys, + () => assert.fail("empty cannot preview"), () => assert.fail("empty cannot save")); + for (const width of [100, 88, 68, 67, 44, 32, 24, 23, 20, 4, 1]) { + for (const rows of [40, 24, 16, 15, 14, 8, 3, 2, 1]) { + terminal.rows = rows; + const height = Math.min(22, Math.max(1, rows - 2)); + const check = (component: ThemeSelector) => { + const lines = component.render(width); + assert.equal(lines.length, height, `${width}x${rows} height`); + assert(lines.every(line => visibleWidth(line) === width), `${width}x${rows} width`); + }; + check(selector); check(empty); + for (const input of [down, right, "\r", right, up, up, down]) { + selector.handleInput(input); check(selector); + } + empty.handleInput(down); empty.handleInput(right); empty.handleInput("\r"); check(empty); + } + } + assert.deepEqual(finished, [], "errors and empty palettes cannot confirm"); +}); + +test("preview errors and missing palettes block save, keep their status slot, and allow recovery or cancel", () => { + const done: Array = []; + const selector = new ThemeSelector([ + { label: "Empty", variants: [] }, + { label: "Missing", variants: [{ name: "missing", label: "Unavailable" }] }, + { label: "Good", variants: [{ name: "good", label: "Default" }] }, + ], undefined, { terminal: { rows: 40 } as TUI["terminal"], requestRender() {} }, () => theme, keys, + name => name === "missing" ? "Could not load missing. Run /reload." : undefined, value => done.push(value)); + assert.match(selector.render(88).join("\n"), /No palettes available/); + selector.handleInput("\r"); assert.deepEqual(done, []); + selector.handleInput(down); + assert.match(selector.render(88).join("\n"), /Could not load missing/); + selector.handleInput("\r"); assert.deepEqual(done, []); + selector.handleInput(down); + assert.doesNotMatch(selector.render(88).join("\n"), /Could not load missing/); + selector.handleInput("\r"); assert.deepEqual(done, ["good"]); + const emptyDone: Array = []; + const empty = new ThemeSelector([], undefined, { terminal: { rows: 40 } as TUI["terminal"], requestRender() {} }, + () => theme, keys, () => assert.fail("empty cannot preview"), value => emptyDone.push(value)); + assert.match(empty.render(88).join("\n"), /No themes available/); + empty.handleInput("\r"); assert.deepEqual(emptyDone, []); + empty.handleInput("\x03"); assert.deepEqual(emptyDone, [undefined]); +}); + +test("many themes and palettes scroll within independent fixed viewports with position cues", () => { + const choices = Array.from({ length: 100 }, (_, i) => ({ label: `Theme ${i}`, variants: + Array.from({ length: 100 }, (_, j) => ({ name: `${i}-${j}`, label: `Palette ${j}`, color: "#f38ba8" })) })); + const terminal = { rows: 40 } as TUI["terminal"]; + const selector = new ThemeSelector(choices, "0-0", { terminal, requestRender() {} }, () => theme, keys, + () => undefined, () => {}); + for (const width of [88, 40]) { + for (let i = 0; i < 100; i++) { + const text = selector.render(width).join("\n"); + assert.match(text, new RegExp(`› Theme ${i}(?![0-9])`)); + assert.match(text, new RegExp(`Themes ${i + 1}/100`)); + assert.match(text, /↑\d|↓\d/); + selector.handleInput(down); + } + for (let j = 0; j < 100; j++) { + const text = selector.render(width).join("\n"); + assert.match(text, new RegExp(`› ████ Palette ${j}(?![0-9])`)); + assert.match(text, new RegExp(`Palettes ${j + 1}/100`)); + selector.handleInput(right); + } + } + selector.handleInput(right); selector.handleInput(down); selector.handleInput(up); + assert.match(selector.render(88).join("\n"), /› ████ Palette 1(?![0-9])/); +}); + +test("palette swatches retain their concrete colors and selected rows have a full-width background", () => { + initTheme("dark"); + const create = Reflect.get(InteractiveMode.prototype, "createExtensionUIContext"); + const ui = create.call({}) as ExtensionContext["ui"]; + const live = ui.getTheme("dark")!; + const selector = new ThemeSelector(slateThemeChoices(), "catppuccin-mocha-pink", { + terminal: { rows: 40 } as TUI["terminal"], requestRender() {}, + }, () => live, keys, () => undefined, () => {}); + const text = selector.render(88).join("\n"); + for (const color of ["#f5c2e7", "#f38ba8", "#b4befe"]) { + assert(text.includes(live.style("████", { fg: parseColor(color) }))); + } + assert(text.includes(live.style("› catppuccin-mocha".padEnd(38), { fg: "accent", bg: "selectedBg", bold: true }))); +}); + test("non-terminal modes do not start a selector or change themes", async () => { for (const mode of ["rpc", "print", "json"]) { let notices = 0; From 3942bb6fc2770b7c234980b8dd89203e67be92e3 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Tue, 3 Nov 2026 11:25:06 +0530 Subject: [PATCH 08/14] =?UTF-8?q?feat(pi-slate):=20add=20Latte,=20Frapp?= =?UTF-8?q?=C3=A9,=20and=20Macchiato=20with=2015=20palettes=20per=20flavor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Theme picker now groups flavours into Light (Latte, Frappé) and Dark (Macchiato, Mocha) sections - Every flavour ships 15 palettes (rosewater through lavender plus default); Mocha Quiet stays legacy - Palette names are selectable directly: /slate theme , /slate style - Effort ramps follow each flavour's border-to-accent direction --- pi-slate/CHANGELOG.md | 10 +- pi-slate/README.md | 31 ++-- pi-slate/extensions/pi-slate/catppuccin.ts | 138 +++++++++++------ pi-slate/extensions/pi-slate/layout.ts | 11 +- .../extensions/pi-slate/theme-selector.ts | 88 +++++++---- pi-slate/extensions/pi-slate/themes.ts | 3 +- pi-slate/llms.txt | 2 +- pi-slate/tests/catppuccin.test.ts | 104 ++++++++++--- pi-slate/tests/layout.test.mjs | 19 ++- pi-slate/tests/theme-selector.test.ts | 142 ++++++++++++------ pi-slate/tests/themes.test.ts | 33 ++-- pi-slate/themes/catppuccin-frappe-blue.json | 107 +++++++++++++ .../themes/catppuccin-frappe-default.json | 107 +++++++++++++ .../themes/catppuccin-frappe-flamingo.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-green.json | 107 +++++++++++++ .../themes/catppuccin-frappe-lavender.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-maroon.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-mauve.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-peach.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-pink.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-red.json | 107 +++++++++++++ .../themes/catppuccin-frappe-rosewater.json | 107 +++++++++++++ .../themes/catppuccin-frappe-sapphire.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-sky.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-teal.json | 107 +++++++++++++ pi-slate/themes/catppuccin-frappe-yellow.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-blue.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-default.json | 107 +++++++++++++ .../themes/catppuccin-latte-flamingo.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-green.json | 107 +++++++++++++ .../themes/catppuccin-latte-lavender.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-maroon.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-mauve.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-peach.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-pink.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-red.json | 107 +++++++++++++ .../themes/catppuccin-latte-rosewater.json | 107 +++++++++++++ .../themes/catppuccin-latte-sapphire.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-sky.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-teal.json | 107 +++++++++++++ pi-slate/themes/catppuccin-latte-yellow.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-blue.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-default.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-flamingo.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-green.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-lavender.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-maroon.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-mauve.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-peach.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-pink.json | 107 +++++++++++++ pi-slate/themes/catppuccin-macchiato-red.json | 107 +++++++++++++ .../catppuccin-macchiato-rosewater.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-sapphire.json | 107 +++++++++++++ pi-slate/themes/catppuccin-macchiato-sky.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-teal.json | 107 +++++++++++++ .../themes/catppuccin-macchiato-yellow.json | 107 +++++++++++++ pi-slate/themes/catppuccin-mocha-blue.json | 107 +++++++++++++ .../themes/catppuccin-mocha-flamingo.json | 107 +++++++++++++ pi-slate/themes/catppuccin-mocha-green.json | 107 +++++++++++++ pi-slate/themes/catppuccin-mocha-maroon.json | 107 +++++++++++++ .../themes/catppuccin-mocha-rosewater.json | 107 +++++++++++++ pi-slate/themes/catppuccin-mocha-sky.json | 107 +++++++++++++ pi-slate/themes/catppuccin-mocha-yellow.json | 107 +++++++++++++ 63 files changed, 5968 insertions(+), 177 deletions(-) create mode 100644 pi-slate/themes/catppuccin-frappe-blue.json create mode 100644 pi-slate/themes/catppuccin-frappe-default.json create mode 100644 pi-slate/themes/catppuccin-frappe-flamingo.json create mode 100644 pi-slate/themes/catppuccin-frappe-green.json create mode 100644 pi-slate/themes/catppuccin-frappe-lavender.json create mode 100644 pi-slate/themes/catppuccin-frappe-maroon.json create mode 100644 pi-slate/themes/catppuccin-frappe-mauve.json create mode 100644 pi-slate/themes/catppuccin-frappe-peach.json create mode 100644 pi-slate/themes/catppuccin-frappe-pink.json create mode 100644 pi-slate/themes/catppuccin-frappe-red.json create mode 100644 pi-slate/themes/catppuccin-frappe-rosewater.json create mode 100644 pi-slate/themes/catppuccin-frappe-sapphire.json create mode 100644 pi-slate/themes/catppuccin-frappe-sky.json create mode 100644 pi-slate/themes/catppuccin-frappe-teal.json create mode 100644 pi-slate/themes/catppuccin-frappe-yellow.json create mode 100644 pi-slate/themes/catppuccin-latte-blue.json create mode 100644 pi-slate/themes/catppuccin-latte-default.json create mode 100644 pi-slate/themes/catppuccin-latte-flamingo.json create mode 100644 pi-slate/themes/catppuccin-latte-green.json create mode 100644 pi-slate/themes/catppuccin-latte-lavender.json create mode 100644 pi-slate/themes/catppuccin-latte-maroon.json create mode 100644 pi-slate/themes/catppuccin-latte-mauve.json create mode 100644 pi-slate/themes/catppuccin-latte-peach.json create mode 100644 pi-slate/themes/catppuccin-latte-pink.json create mode 100644 pi-slate/themes/catppuccin-latte-red.json create mode 100644 pi-slate/themes/catppuccin-latte-rosewater.json create mode 100644 pi-slate/themes/catppuccin-latte-sapphire.json create mode 100644 pi-slate/themes/catppuccin-latte-sky.json create mode 100644 pi-slate/themes/catppuccin-latte-teal.json create mode 100644 pi-slate/themes/catppuccin-latte-yellow.json create mode 100644 pi-slate/themes/catppuccin-macchiato-blue.json create mode 100644 pi-slate/themes/catppuccin-macchiato-default.json create mode 100644 pi-slate/themes/catppuccin-macchiato-flamingo.json create mode 100644 pi-slate/themes/catppuccin-macchiato-green.json create mode 100644 pi-slate/themes/catppuccin-macchiato-lavender.json create mode 100644 pi-slate/themes/catppuccin-macchiato-maroon.json create mode 100644 pi-slate/themes/catppuccin-macchiato-mauve.json create mode 100644 pi-slate/themes/catppuccin-macchiato-peach.json create mode 100644 pi-slate/themes/catppuccin-macchiato-pink.json create mode 100644 pi-slate/themes/catppuccin-macchiato-red.json create mode 100644 pi-slate/themes/catppuccin-macchiato-rosewater.json create mode 100644 pi-slate/themes/catppuccin-macchiato-sapphire.json create mode 100644 pi-slate/themes/catppuccin-macchiato-sky.json create mode 100644 pi-slate/themes/catppuccin-macchiato-teal.json create mode 100644 pi-slate/themes/catppuccin-macchiato-yellow.json create mode 100644 pi-slate/themes/catppuccin-mocha-blue.json create mode 100644 pi-slate/themes/catppuccin-mocha-flamingo.json create mode 100644 pi-slate/themes/catppuccin-mocha-green.json create mode 100644 pi-slate/themes/catppuccin-mocha-maroon.json create mode 100644 pi-slate/themes/catppuccin-mocha-rosewater.json create mode 100644 pi-slate/themes/catppuccin-mocha-sky.json create mode 100644 pi-slate/themes/catppuccin-mocha-yellow.json diff --git a/pi-slate/CHANGELOG.md b/pi-slate/CHANGELOG.md index ee827d89..653c8435 100644 --- a/pi-slate/CHANGELOG.md +++ b/pi-slate/CHANGELOG.md @@ -5,10 +5,16 @@ ### Added - `/theme` opens a larger centered live-preview selector with fixed geometry, scrolling theme and color-swatch palette lists, selection/overflow cues, and responsive two-column, stacked, or tiny-screen layouts. Up/down changes themes, left/right changes palettes, Enter saves, and Escape/Ctrl+C restores the original visible theme without writing settings. `/theme slate` and `/slate theme [name]` remain supported; Pi's `/settings` is unchanged. -- Catppuccin Mocha Red (`#f38ba8`) and Lavender (`#b4befe`) variants alongside existing Pink; variant choices survive navigation within the selector. +- The selector groups Catppuccin under **Light** (Latte, Frappé) and **Dark** (Macchiato, Mocha) sections; Tokyo Night and Black Metal sit under Dark. +- Catppuccin Latte (light), Frappé, and Macchiato flavors alongside Mocha, all taken from the official palette (https://catppuccin.com/palette). +- Each flavor ships the same 15 palettes: Rosewater, Flamingo, Pink, Mauve, Red, Maroon, Peach, Yellow, Green, Teal, Sky, Sapphire, Blue, Lavender, and Default. Palette choices survive navigation within the selector. - On Pi 1.4, cancelling after preview restores the original palette but automatic terminal-appearance following resumes only after `/reload` (public theme-instance API limitation). Saved theme and light/dark settings are untouched. -- `catppuccin-mocha-pink` theme, selected by its full name with `/slate theme catppuccin-mocha-pink` or `/slate style catppuccin-mocha-pink`. +### Changed + +- `/slate theme ` and `/slate style ` accept bare palette names; `catppuccin-mocha-pink` and every full `catppuccin--` name still resolve. Pink is now labelled `Pink` instead of its full theme name. +- `/slate theme [palette]` switches flavor without losing the current palette. Mocha Quiet remains available as a legacy palette. +- Effort ramps follow each flavor: dark flavors brighten toward the accent, light flavors deepen toward it. ### Fixed diff --git a/pi-slate/README.md b/pi-slate/README.md index 302116b6..437ac81f 100644 --- a/pi-slate/README.md +++ b/pi-slate/README.md @@ -69,30 +69,35 @@ Other extensions can publish detached terminals or jobs to the sidebar's task sh Open `/theme` for a larger, centered theme selector with live preview (`/theme slate` remains an alias): - **↑ / ↓** selects and immediately applies a theme; **← / →** changes its palette. Dedicated theme and color-swatch lists show selection, position, and overflow counts; both scroll without changing the dialog's shape. +- Themes are grouped into **Light** and **Dark** sections. Light holds Catppuccin Latte and Frappé; Dark holds Macchiato, Mocha, Tokyo Night, and Black Metal. - Wide terminals use two columns; narrow terminals stack the lists, and very small screens keep the selected identity and essential controls. The dialog resizes to fit the terminal, not the selected theme or palette. -- The Catppuccin picker cycles through **Pink** → **Red** (`#f38ba8`) → **Lavender** (`#b4befe`). Each theme remembers its variant while the selector is open. Existing styles remain available through legacy commands; opening from one preserves it in a separate current-palette row. +- Every Catppuccin flavor carries the same **15 palettes**: Rosewater, Flamingo, Pink, Mauve, Red, Maroon, Peach, Yellow, Green, Teal, Sky, Sapphire, Blue, Lavender, and Default (a neutral frame that reuses the flavor's lavender accent). Each flavor remembers its palette while the selector is open. The legacy Mocha Quiet style stays selectable and opens in a separate current-palette row. - **Enter** confirms and saves; **Escape** (or **Ctrl+C**) restores the original visible theme without changing saved theme settings or Slate configuration. +All colors come from the official Catppuccin palette (https://catppuccin.com/palette). Every bundled theme is generated from one registry, so `npm run themes` reproduces `themes/*.json` exactly. + The selector requires interactive terminal mode. Pi 1.4 has no built-in `/theme` command; core theme selection remains in `/settings`. After previewing and cancelling, Pi's public theme-instance API restores the original palette but pauses automatic terminal-appearance following (including `system` and light/dark pairs) until `/reload`. Merely opening and cancelling without previewing does not affect appearance following. Direct selection with `/slate theme ` remains supported: -| Themes | Accent | +| Command | Result | | --- | --- | -| `tokyo-night` | 🟦 | -| `black-metal` | ⬛ | -| `quiet` | ⬜ | -| `mauve` | 🟪 | -| `catppuccin-mocha-pink` | 🩷 | -| `red` / `lavender` | `#f38ba8` / `#b4befe` | -| `sapphire` | 🟦 | -| `peach` | 🟧 | -| `teal` | 🟩 | -| `default` | Pi's system theme | +| `/slate theme system` (or `default`) | Pi's system theme | +| `/slate theme tokyo-night` | Tokyo Night (dark) | +| `/slate theme black-metal` | Black Metal (dark) | +| `/slate theme latte` | Catppuccin Latte (light) | +| `/slate theme frappe` (or `frappé`) | Catppuccin Frappé (light) | +| `/slate theme macchiato` | Catppuccin Macchiato (dark) | +| `/slate theme mocha` | Catppuccin Mocha (dark) | +| `/slate theme ` | e.g. `latte rosewater`, `mocha mauve`, `frappe teal` | +| `/slate theme ` | Sets that palette on the current flavor | +| `/slate style ` | Retained Catppuccin alias | + +The palette names are `rosewater`, `flamingo`, `pink`, `mauve`, `red`, `maroon`, `peach`, `yellow`, `green`, `teal`, `sky`, `sapphire`, `blue`, `lavender`, and `default`; `quiet` stays a legacy Mocha alias. Full theme names such as `catppuccin-latte-mauve` are accepted too. Black Metal is an optional bundled theme, not an install default: pick it with `/slate theme black-metal` or `/theme` (`/slate theme` also opens the menu). Confirmed selections are saved and also appear in `/settings`. -With `/slate effort-border on`, bundled themes use a restrained effort ramp from their neutral border toward their border accent: blue for Tokyo Night, the selected Catppuccin style, cool grays for Quiet, and grayscale for Black Metal. Higher effort increases intensity instead of switching to unrelated hues; the effort label remains the precise cue. The sidebar stays neutral, bash mode keeps its separate color, and external themes retain their own effort colors. These are native theme tokens, so Pi's stock editor also uses the ramp when a bundled Slate theme is selected. Effort-colored Slate frames remain off by default. +With `/slate effort-border on`, bundled themes use a restrained effort ramp from their neutral border toward their border accent: blue for Tokyo Night, the selected Catppuccin palette, cool grays for Quiet, and grayscale for Black Metal. Higher effort increases intensity instead of switching to unrelated hues; on light flavors the ramp deepens toward the accent instead of brightening. The effort label remains the precise cue. The sidebar stays neutral, bash mode keeps its separate color, and external themes retain their own effort colors. These are native theme tokens, so Pi's stock editor also uses the ramp when a bundled Slate theme is selected. Effort-colored Slate frames remain off by default. ### Prompt Templates diff --git a/pi-slate/extensions/pi-slate/catppuccin.ts b/pi-slate/extensions/pi-slate/catppuccin.ts index 2ce12ed8..d6dbcdd3 100644 --- a/pi-slate/extensions/pi-slate/catppuccin.ts +++ b/pi-slate/extensions/pi-slate/catppuccin.ts @@ -1,25 +1,57 @@ import { effortPalette } from "./theme-palette.ts"; -export const FLAVORS = ["mocha"] as const; -export const STYLES = ["default", "quiet", "mauve", "pink", "red", "lavender", "sapphire", "peach", "teal"] as const; +/** Official Catppuccin flavors (https://catppuccin.com/palette). */ +export const FLAVORS = ["latte", "frappe", "macchiato", "mocha"] as const; export type Flavor = (typeof FLAVORS)[number]; -export type Style = (typeof STYLES)[number]; export const FLAVOR_LABELS: Record = { + latte: "Latte", + frappe: "Frappé", + macchiato: "Macchiato", mocha: "Mocha", }; +/** Picker grouping; Frappé is mid-tone, but it sits with Latte under Light. */ +export const FLAVOR_SECTIONS: readonly { label: string; flavors: readonly Flavor[] }[] = [ + { label: "Light", flavors: ["latte", "frappe"] }, + { label: "Dark", flavors: ["macchiato", "mocha"] }, +]; + +/** Every accent hue Catppuccin defines, plus the neutral Default style. */ +const ACCENT_STYLES = [ + "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", + "green", "teal", "sky", "sapphire", "blue", "lavender", +] as const; + +export const STYLES = [...ACCENT_STYLES, "default", "quiet"] as const; + +export type Style = (typeof STYLES)[number]; + +/** The 15 palette options each flavor ships; `quiet` stays a legacy Mocha style. */ +export const PALETTE_STYLES: readonly Style[] = STYLES.filter((style) => style !== "quiet"); + +export function stylesFor(flavor: Flavor): readonly Style[] { + return flavor === "mocha" ? STYLES : PALETTE_STYLES; +} + export const STYLE_LABELS: Record = { - default: "Default", - quiet: "Quiet", + rosewater: "Rosewater", + flamingo: "Flamingo", + pink: "Pink", mauve: "Mauve", - pink: "catppuccin-mocha-pink", red: "Red", - lavender: "Lavender", - sapphire: "Sapphire", + maroon: "Maroon", peach: "Peach", + yellow: "Yellow", + green: "Green", teal: "Teal", + sky: "Sky", + sapphire: "Sapphire", + blue: "Blue", + lavender: "Lavender", + default: "Default", + quiet: "Quiet", }; export const DEFAULT_FLAVOR: Flavor = "mocha"; @@ -34,6 +66,33 @@ type Swatch = type Palette = Record; const PALETTES: Record = { + latte: { + rosewater: "#dc8a78", flamingo: "#dd7878", pink: "#ea76cb", mauve: "#8839ef", + red: "#d20f39", maroon: "#e64553", peach: "#fe640b", yellow: "#df8e1d", + green: "#40a02b", teal: "#179299", sky: "#04a5e5", sapphire: "#209fb5", + blue: "#1e66f5", lavender: "#7287fd", text: "#4c4f69", subtext1: "#5c5f77", + subtext0: "#6c6f85", overlay2: "#7c7f93", overlay1: "#8c8fa1", overlay0: "#9ca0b0", + surface2: "#acb0be", surface1: "#bcc0cc", surface0: "#ccd0da", + base: "#eff1f5", mantle: "#e6e9ef", crust: "#dce0e8", + }, + frappe: { + rosewater: "#f2d5cf", flamingo: "#eebebe", pink: "#f4b8e4", mauve: "#ca9ee6", + red: "#e78284", maroon: "#ea999c", peach: "#ef9f76", yellow: "#e5c890", + green: "#a6d189", teal: "#81c8be", sky: "#99d1db", sapphire: "#85c1dc", + blue: "#8caaee", lavender: "#babbf1", text: "#c6d0f5", subtext1: "#b5bfe2", + subtext0: "#a5adce", overlay2: "#949cbb", overlay1: "#838ba7", overlay0: "#737994", + surface2: "#626880", surface1: "#51576d", surface0: "#414559", + base: "#303446", mantle: "#292c3c", crust: "#232634", + }, + macchiato: { + rosewater: "#f4dbd6", flamingo: "#f0c6c6", pink: "#f5bde6", mauve: "#c6a0f6", + red: "#ed8796", maroon: "#ee99a0", peach: "#f5a97f", yellow: "#eed49f", + green: "#a6da95", teal: "#8bd5ca", sky: "#91d7e3", sapphire: "#7dc4e4", + blue: "#8aadf4", lavender: "#b7bdf8", text: "#cad3f5", subtext1: "#b8c0e0", + subtext0: "#a5adcb", overlay2: "#939ab7", overlay1: "#8087a2", overlay0: "#6e738d", + surface2: "#5b6078", surface1: "#494d64", surface0: "#363a4f", + base: "#24273a", mantle: "#1e2030", crust: "#181926", + }, mocha: { rosewater: "#f5e0dc", flamingo: "#f2cdcd", pink: "#f5c2e7", mauve: "#cba6f7", red: "#f38ba8", maroon: "#eba0ac", peach: "#fab387", yellow: "#f9e2af", @@ -45,16 +104,24 @@ const PALETTES: Record = { }, }; -const STYLE_ROLES: Record = { +type StyleRoles = { accent: Swatch; border: Swatch; heading: Swatch }; + +const STYLE_ROLES: Record = { + ...Object.fromEntries(ACCENT_STYLES.map((style) => [style, { + accent: style, border: "overlay0", heading: style, + } satisfies StyleRoles])), default: { accent: "lavender", border: "overlay0", heading: "text" }, quiet: { accent: "subtext1", border: "surface2", heading: "subtext1" }, - mauve: { accent: "mauve", border: "overlay0", heading: "mauve" }, - pink: { accent: "pink", border: "overlay0", heading: "pink" }, - red: { accent: "red", border: "overlay0", heading: "red" }, - lavender: { accent: "lavender", border: "overlay0", heading: "lavender" }, - sapphire: { accent: "sapphire", border: "overlay0", heading: "sapphire" }, - peach: { accent: "peach", border: "overlay0", heading: "peach" }, - teal: { accent: "teal", border: "overlay0", heading: "teal" }, +} as Record; + +export const THEME_SCHEMA = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json"; + +export type CatppuccinThemeJson = { + $schema: string; + name: string; + vars: Record; + colors: Record; + export: { pageBg: string; cardBg: string; infoBg: string }; }; export function catppuccinThemeName(flavor: Flavor, style: Style): string { @@ -63,30 +130,29 @@ export function catppuccinThemeName(flavor: Flavor, style: Style): string { export function parseFlavor(raw: string): Flavor | undefined { const value = raw.trim().toLowerCase().normalize("NFD").replace(/\p{M}/gu, ""); + if (value === "frappe") return "frappe"; return FLAVORS.find((flavor) => flavor === value); } export function parseStyle(raw: string): Style | undefined { const value = raw.trim().toLowerCase(); if (value === "canonical") return "default"; - // Labels are the user-facing names: plain style keys except Pink, which is - // selected by its full theme name (`catppuccin-mocha-pink`). - return STYLES.find((style) => STYLE_LABELS[style].toLowerCase() === value); + // Accept the bare style key (`pink`) and any full theme name (`catppuccin-mocha-pink`). + const candidate = /^catppuccin-[a-z]+-([a-z]+)$/.exec(value)?.[1] ?? value; + return STYLES.find((style) => style === candidate); } -/** Theme files encode the style key (e.g. `catppuccin-mocha-pink`), not the label. */ -function styleFromThemeName(value: string): Style | undefined { - if (value === "canonical") return "default"; - return STYLES.find((style) => style === value); +export function flavorSection(flavor: Flavor): string { + return FLAVOR_SECTIONS.find((section) => section.flavors.includes(flavor))!.label; } export function parseCatppuccinTheme(name: string | undefined): { flavor: Flavor; style: Style } | undefined { if (!name) return undefined; const match = /^catppuccin-([a-z]+)-([a-z]+)$/.exec(name); if (!match) return undefined; - const flavor = parseFlavor(match[1]); - const style = styleFromThemeName(match[2]); - if (!flavor || !style) return undefined; + const flavor = parseFlavor(match[1]!); + const style = STYLES.find((candidate) => candidate === match[2]); + if (!flavor || !style || !stylesFor(flavor).includes(style)) return undefined; return { flavor, style }; } @@ -97,27 +163,15 @@ export function resolveCatppuccinTheme( ): { flavor: Flavor; style: Style; name: string } { const current = parseCatppuccinTheme(name); const nextFlavor = flavor ?? current?.flavor ?? DEFAULT_FLAVOR; - const nextStyle = style ?? current?.style ?? DEFAULT_STYLE; + const requested = style ?? current?.style ?? DEFAULT_STYLE; + const nextStyle = stylesFor(nextFlavor).includes(requested) ? requested : DEFAULT_STYLE; return { flavor: nextFlavor, style: nextStyle, name: catppuccinThemeName(nextFlavor, nextStyle) }; } export function themeMessage(flavor: Flavor, style: Style): string { - const label = STYLE_LABELS[style]; - return label === catppuccinThemeName(flavor, style) - ? `Theme set to ${label}` - : `Theme set to ${FLAVOR_LABELS[flavor]} · ${label}`; + return `Theme set to ${FLAVOR_LABELS[flavor]} · ${STYLE_LABELS[style]}`; } -export const THEME_SCHEMA = "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json"; - -export type CatppuccinThemeJson = { - $schema: string; - name: string; - vars: Record; - colors: Record; - export: { pageBg: string; cardBg: string; infoBg: string }; -}; - export function buildCatppuccinTheme(flavor: Flavor, style: Style): CatppuccinThemeJson { const palette = PALETTES[flavor]; const roles = STYLE_ROLES[style]; @@ -195,5 +249,5 @@ export function buildCatppuccinTheme(flavor: Flavor, style: Style): CatppuccinTh } export function allCatppuccinThemes(): CatppuccinThemeJson[] { - return FLAVORS.flatMap((flavor) => STYLES.map((style) => buildCatppuccinTheme(flavor, style))); + return FLAVORS.flatMap((flavor) => stylesFor(flavor).map((style) => buildCatppuccinTheme(flavor, style))); } diff --git a/pi-slate/extensions/pi-slate/layout.ts b/pi-slate/extensions/pi-slate/layout.ts index 28f64217..3e36854a 100644 --- a/pi-slate/extensions/pi-slate/layout.ts +++ b/pi-slate/extensions/pi-slate/layout.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { parseFlavor, parseStyle, type Flavor, type Style } from "./catppuccin.ts"; +import { FLAVORS, parseFlavor, parseStyle, STYLES, stylesFor, type Flavor, type Style } from "./catppuccin.ts"; import { parseSurfaces, SURFACES, type Surface } from "./surfaces.ts"; import { isProgressStyle, type ProgressStyle } from "./working-wave.ts"; @@ -303,7 +303,7 @@ export const SLATE_VERSION = JSON.parse( ).version as string; export const SLATE_USAGE = - "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme [default|tokyo-night|black-metal|quiet|mauve|catppuccin-mocha-pink|red|lavender|sapphire|peach|teal] | style [default|quiet|mauve|catppuccin-mocha-pink|red|lavender|sapphire|peach|teal] | bug [file|open]"; + "Usage: /slate session [mcp|skills|commands|tasks|image] | surfaces [set |full|none] | density [comfortable|compact] | footer [standard|minimal] | width [default|narrow|medium|wide|] | focused [on|off] | pid [on|off] | effort-border [on|off] | logo [original|minimal] | progress [waves|dna-helix|snake|cylon-scanner] | message-length [default|all|] | theme [system|tokyo-night|black-metal| [style]|