mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
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.
This commit is contained in:
parent
33a5bfa3c4
commit
833410e02b
5 changed files with 163 additions and 14 deletions
|
|
@ -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<string | null>(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 (
|
||||
<DialogPrimitive.Root onOpenChange={setCommandPaletteOpen} open={open}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/15 backdrop-blur-[1px] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
||||
{/* Transparent overlay: keeps click-away + focus trap, but no dim/blur. */}
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[200]" />
|
||||
<DialogPrimitive.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
||||
className={cn(
|
||||
HUD_POSITION,
|
||||
HUD_SURFACE,
|
||||
'z-[210] w-[min(34rem,calc(100vw-2rem))] overflow-hidden duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95'
|
||||
)}
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" filter={paletteFilter} loop>
|
||||
|
|
@ -581,6 +587,7 @@ export function CommandPalette() {
|
|||
</button>
|
||||
)}
|
||||
<CommandInput
|
||||
className={HUD_TEXT}
|
||||
onKeyDown={event => {
|
||||
if (!activePage) {
|
||||
return
|
||||
|
|
@ -598,7 +605,7 @@ export function CommandPalette() {
|
|||
placeholder={placeholder}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="max-h-[min(24rem,60vh)]">
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
|
|
@ -606,7 +613,7 @@ export function CommandPalette() {
|
|||
)}
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandGroup
|
||||
className="**:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-wider **:[[cmdk-group-heading]]:text-[0.6875rem] **:[[cmdk-group-heading]]:text-muted-foreground/70"
|
||||
className={HUD_HEADING}
|
||||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
|
|
@ -617,18 +624,18 @@ export function CommandPalette() {
|
|||
|
||||
return (
|
||||
<CommandItem
|
||||
className="gap-2.5"
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{keys && <KbdGroup className="ml-auto" keys={keys} />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-4 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
|
|
|
|||
20
apps/desktop/src/app/floating-hud.ts
Normal file
20
apps/desktop/src/app/floating-hud.ts
Normal file
|
|
@ -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)'
|
||||
|
|
@ -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(
|
||||
<div className="fixed inset-0 z-[220] flex select-none items-center justify-center">
|
||||
<>
|
||||
{/* Transparent click-catcher: click-away closes, but no dim/blur. */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/15 backdrop-blur-[1px]"
|
||||
className="fixed inset-0 z-[219]"
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
closeSwitcher()
|
||||
}}
|
||||
/>
|
||||
<div className="relative max-h-[min(26rem,70vh)] w-[min(20rem,calc(100vw-2rem))] overflow-y-auto rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) p-1 shadow-lg">
|
||||
<div
|
||||
className={cn(
|
||||
HUD_POSITION,
|
||||
HUD_SURFACE,
|
||||
'dt-portal-scrollbar z-[220] max-h-[min(22rem,64vh)] w-[min(19rem,calc(100vw-2rem))] select-none overflow-y-auto p-1'
|
||||
)}
|
||||
>
|
||||
{sessions.map((session, i) => {
|
||||
const selected = i === index
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 text-xs leading-tight',
|
||||
'flex cursor-pointer items-center rounded leading-tight',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT,
|
||||
selected ? 'bg-accent text-accent-foreground' : 'text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background)'
|
||||
)}
|
||||
key={session.id}
|
||||
|
|
@ -80,7 +90,7 @@ export function SessionSwitcher() {
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [] })
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue