From 833410e02bc3c517b5648913ab38455e9bb85dbc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 9 Jun 2026 23:37:50 -0500 Subject: [PATCH] feat(desktop): theme the terminal ANSI palette + restyle the Cmd-K / Ctrl-Tab HUDs Imported VS Code themes now carry their integrated-terminal ANSI palette (`terminal.ansi*`), keyed to the painted variant (terminal / darkTerminal). The terminal adopts it when the full base-8 set is present and keeps its VS Code defaults otherwise; withSurface still owns the background, so the pane stays translucent. Pull the command palette and session switcher into a shared top-center HUD (`floating-hud.ts`): no dim/blur backdrop, one compact text + item-padding size, sidebar-label-style section headers (brand-tinted, uppercase), and the themed portal scrollbar. --- .../desktop/src/app/command-palette/index.tsx | 25 +++++--- apps/desktop/src/app/floating-hud.ts | 20 +++++++ apps/desktop/src/app/session-switcher.tsx | 20 +++++-- apps/desktop/src/themes/install.test.ts | 54 +++++++++++++++++ apps/desktop/src/themes/vscode.test.ts | 58 +++++++++++++++++++ 5 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/app/floating-hud.ts diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index bf693206d25..3872d24d5f9 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -4,6 +4,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui' import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' +import { HUD_HEADING, HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from '@/app/floating-hud' import { setTerminalTakeover } from '@/app/right-sidebar/store' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { KbdGroup } from '@/components/ui/kbd' @@ -203,7 +204,7 @@ export function CommandPalette() { const open = useStore($commandPaletteOpen) const bindings = useStore($bindings) const navigate = useNavigate() - const { availableThemes, setMode, setTheme } = useTheme() + const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme() const [search, setSearch] = useState('') const [page, setPage] = useState(null) @@ -536,7 +537,7 @@ export function CommandPalette() { groups: [] } }), - [availableThemes, setMode, setTheme, t] + [availableThemes, resolvedMode, setMode, setTheme, t, themeName] ) const activePage = page ? subPages[page] : null @@ -561,10 +562,15 @@ export function CommandPalette() { return ( - + {/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */} + {t.commandCenter.paletteTitle} @@ -581,6 +587,7 @@ export function CommandPalette() { )} { if (!activePage) { return @@ -598,7 +605,7 @@ export function CommandPalette() { placeholder={placeholder} value={search} /> - + {page === 'install-theme' ? ( ) : ( @@ -606,7 +613,7 @@ export function CommandPalette() { )} {visibleGroups.map((group, index) => ( @@ -617,18 +624,18 @@ export function CommandPalette() { return ( handleSelect(item)} value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`} > - + {item.label} {keys && } {item.to && ( )} diff --git a/apps/desktop/src/app/floating-hud.ts b/apps/desktop/src/app/floating-hud.ts new file mode 100644 index 00000000000..5f08c87f52b --- /dev/null +++ b/apps/desktop/src/app/floating-hud.ts @@ -0,0 +1,20 @@ +// Shared chrome for the top-center floating HUDs (command palette + session +// switcher). They pin just under the title bar, centered, and lean on a crisp +// border + shadow to separate from the app — no dimming/blurring backdrop. +// Each caller layers on its own z-index, width, and overflow. +export const HUD_POSITION = 'fixed left-1/2 top-3 -translate-x-1/2' + +export const HUD_SURFACE = 'rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-xl' + +// One row/text size for both HUDs (compact — two notches under `text-sm`). +export const HUD_TEXT = 'text-xs' + +// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows +// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`). +export const HUD_ITEM = 'gap-2 px-2 py-1' + +// Section headings styled like the sidebar panel labels: brand-tinted, uppercase, +// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group +// heading via the universal-descendant variant. +export const HUD_HEADING = + '**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)' diff --git a/apps/desktop/src/app/session-switcher.tsx b/apps/desktop/src/app/session-switcher.tsx index fe4bf8e9236..c2e272f173a 100644 --- a/apps/desktop/src/app/session-switcher.tsx +++ b/apps/desktop/src/app/session-switcher.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' import { $attentionSessionIds, $workingSessionIds } from '@/store/session' import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher' +import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud' import { sessionRoute } from './routes' // Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows @@ -39,22 +40,31 @@ export function SessionSwitcher() { } return createPortal( -
+ <> + {/* Transparent click-catcher: click-away closes, but no dim/blur. */}
{ e.preventDefault() closeSwitcher() }} /> -
+
{sessions.map((session, i) => { const selected = i === index return (
-
, + , document.body ) } diff --git a/apps/desktop/src/themes/install.test.ts b/apps/desktop/src/themes/install.test.ts index de70c58f9de..42b777681b3 100644 --- a/apps/desktop/src/themes/install.test.ts +++ b/apps/desktop/src/themes/install.test.ts @@ -8,6 +8,21 @@ import { buildThemeFromMarketplace } from './install' const themeJson = (type: 'light' | 'dark', background: string, foreground: string) => JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground } }) +// A full base-8 ANSI set keyed off `red` so each variant is distinguishable. +const ansiColors = (red: string) => ({ + 'terminal.ansiBlack': '#000000', + 'terminal.ansiRed': red, + 'terminal.ansiGreen': '#00aa00', + 'terminal.ansiYellow': '#aaaa00', + 'terminal.ansiBlue': '#0000aa', + 'terminal.ansiMagenta': '#aa00aa', + 'terminal.ansiCyan': '#00aaaa', + 'terminal.ansiWhite': '#aaaaaa' +}) + +const themeJsonWithAnsi = (type: 'light' | 'dark', background: string, foreground: string, red: string) => + JSON.stringify({ type, colors: { 'editor.background': background, 'editor.foreground': foreground, ...ansiColors(red) } }) + describe('buildThemeFromMarketplace', () => { it('folds a light + dark variant into one family with both slots', () => { const result: DesktopMarketplaceThemeResult = { @@ -57,6 +72,45 @@ describe('buildThemeFromMarketplace', () => { expect(theme.darkColors).toBe(theme.colors) }) + it('keys each variant terminal palette to its mode (terminal / darkTerminal)', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'ryanolsonx.solarized', + displayName: 'Solarized', + themes: [ + { label: 'Solarized Light', uiTheme: 'vs', contents: themeJsonWithAnsi('light', '#fdf6e3', '#586e75', '#dc322f') }, + { label: 'Solarized Dark', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#002b36', '#93a1a1', '#ff5f56') } + ] + } + + const theme = buildThemeFromMarketplace(result) + expect(theme.terminal?.red).toBe('#dc322f') + expect(theme.darkTerminal?.red).toBe('#ff5f56') + }) + + it('reuses the sole variant terminal palette for both modes', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'dracula-theme.theme-dracula', + displayName: 'Dracula', + themes: [{ label: 'Dracula', uiTheme: 'vs-dark', contents: themeJsonWithAnsi('dark', '#282a36', '#f8f8f2', '#ff5555') }] + } + + const theme = buildThemeFromMarketplace(result) + expect(theme.terminal?.red).toBe('#ff5555') + expect(theme.darkTerminal?.red).toBe('#ff5555') + }) + + it('leaves terminal slots unset when no variant ships an ANSI palette', () => { + const result: DesktopMarketplaceThemeResult = { + extensionId: 'x.plain', + displayName: 'Plain', + themes: [{ label: 'Plain', uiTheme: 'vs-dark', contents: themeJson('dark', '#101010', '#fafafa') }] + } + + const theme = buildThemeFromMarketplace(result) + expect(theme.terminal).toBeUndefined() + expect(theme.darkTerminal).toBeUndefined() + }) + it('throws when the extension contributes no themes', () => { expect(() => buildThemeFromMarketplace({ extensionId: 'x.y', displayName: 'X', themes: [] }) diff --git a/apps/desktop/src/themes/vscode.test.ts b/apps/desktop/src/themes/vscode.test.ts index 4ca81fa9a5e..ac7cc9f9bd9 100644 --- a/apps/desktop/src/themes/vscode.test.ts +++ b/apps/desktop/src/themes/vscode.test.ts @@ -110,4 +110,62 @@ describe('convertVscodeColorTheme', () => { it('throws when there is no colors map', () => { expect(() => convertVscodeColorTheme({ name: 'Empty' })).toThrow(/colors/) }) + + const fullAnsi = { + 'terminal.ansiBlack': '#073642', + 'terminal.ansiRed': '#dc322f', + 'terminal.ansiGreen': '#859900', + 'terminal.ansiYellow': '#b58900', + 'terminal.ansiBlue': '#268bd2', + 'terminal.ansiMagenta': '#d33682', + 'terminal.ansiCyan': '#2aa198', + 'terminal.ansiWhite': '#eee8d5', + 'terminal.ansiBrightBlack': '#002b36', + 'terminal.ansiBrightRed': '#cb4b16', + 'terminal.ansiBrightGreen': '#586e75', + 'terminal.ansiBrightYellow': '#657b83', + 'terminal.ansiBrightBlue': '#839496', + 'terminal.ansiBrightMagenta': '#6c71c4', + 'terminal.ansiBrightCyan': '#93a1a1', + 'terminal.ansiBrightWhite': '#fdf6e3' + } + + it('lifts the ANSI palette when the full base-8 set is present', () => { + const { theme } = convertVscodeColorTheme({ + name: 'Solarized Dark', + type: 'dark', + colors: { + 'editor.background': '#002b36', + 'editor.foreground': '#93a1a1', + 'terminal.foreground': '#839496', + 'terminalCursor.foreground': '#93a1a1', + // Alpha selection must survive un-flattened — xterm blends it. + 'terminal.selectionBackground': '#073642aa', + ...fullAnsi + } + }) + + expect(theme.terminal?.red).toBe('#dc322f') + expect(theme.terminal?.brightWhite).toBe('#fdf6e3') + expect(theme.terminal?.foreground).toBe('#839496') + expect(theme.terminal?.cursor).toBe('#93a1a1') + expect(theme.terminal?.selectionBackground).toBe('#073642aa') + // No background slot — the pane keeps the live surface (transparency). + expect('background' in (theme.terminal ?? {})).toBe(false) + }) + + it('keeps the default palette (no terminal slot) when the ANSI set is partial', () => { + const { theme } = convertVscodeColorTheme({ + name: 'Half', + type: 'dark', + colors: { + 'editor.background': '#101010', + 'editor.foreground': '#fafafa', + 'terminal.ansiRed': '#ff0000', + 'terminal.ansiGreen': '#00ff00' + } + }) + + expect(theme.terminal).toBeUndefined() + }) })