Merge remote-tracking branch 'origin/main' into bb/vscode-marketplace-themes

# Conflicts:
#	apps/desktop/electron/main.cjs
#	apps/desktop/src/app/command-palette/index.tsx
#	apps/desktop/src/themes/context.tsx
This commit is contained in:
Brooklyn Nicholson
2026-06-09 23:22:36 -05:00
37 changed files with 1211 additions and 415 deletions
@@ -4,6 +4,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@@ -62,6 +63,7 @@ export function ComposerControls({
}) {
const { t } = useI18n()
const c = t.composer
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
@@ -73,9 +75,9 @@ export function ComposerControls({
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
{canSteer && (
<Tip label={c.steer}>
<Tip label={steerLabel}>
<Button
aria-label={c.steer}
aria-label={steerLabel}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onSteer}
+4 -1
View File
@@ -126,7 +126,10 @@ function ChatHeader({
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className="min-w-0 flex-1"
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
<SessionActionsMenu
align="start"
+86 -24
View File
@@ -4,7 +4,9 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { KbdGroup } from '@/components/ui/kbd'
import { getHermesConfigRecord, listSessions } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
@@ -12,7 +14,6 @@ import {
Activity,
Archive,
BarChart3,
Check,
ChevronLeft,
ChevronRight,
Clock,
@@ -31,12 +32,15 @@ import {
Settings,
Settings2,
Sun,
Terminal,
Users,
Wrench,
Zap
} from '@/lib/icons'
import { comboTokens } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
import { $bindings } from '@/store/keybinds'
import { luminance } from '@/themes/color'
import { type ThemeMode, useTheme } from '@/themes/context'
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
@@ -60,7 +64,8 @@ import { prettyName } from '../settings/helpers'
import { MarketplaceThemePage } from './marketplace-theme-page'
interface PaletteItem {
active?: boolean
/** Keybind action id — its live combo renders as a hotkey hint. */
action?: string
icon: IconComponent
id: string
/** Keep the palette open after running (live-preview pickers like theme/mode). */
@@ -97,6 +102,22 @@ interface SessionEntry {
title: string
}
// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with
// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring
// matching instead: every typed word must literally appear in the item's
// value/keywords, which keeps results tight and predictable.
const paletteFilter = (value: string, search: string, keywords?: string[]): number => {
const needle = search.trim().toLowerCase()
if (!needle) {
return 1
}
const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase()
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
}
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
const toSessionEntry = (session: SessionRow): SessionEntry => ({
@@ -180,8 +201,9 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
export function CommandPalette() {
const { t } = useI18n()
const open = useStore($commandPaletteOpen)
const bindings = useStore($bindings)
const navigate = useNavigate()
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
const { availableThemes, setMode, setTheme } = useTheme()
const [search, setSearch] = useState('')
const [page, setPage] = useState<string | null>(null)
@@ -254,20 +276,61 @@ export function CommandPalette() {
{
heading: cc.goTo,
items: [
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
{ icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
{
action: 'session.new',
icon: Plus,
id: 'nav-new',
keywords: ['chat', 'create'],
label: cc.nav.newChat.title,
run: go(NEW_CHAT_ROUTE)
},
{
action: 'view.showTerminal',
icon: Terminal,
id: 'nav-terminal',
keywords: ['terminal', 'shell', 'console'],
label: t.keybinds.actions['view.showTerminal'],
run: () => setTerminalTakeover(true)
},
{
action: 'nav.settings',
icon: Settings,
id: 'nav-settings',
label: cc.nav.settings.title,
run: go(SETTINGS_ROUTE)
},
{
action: 'nav.skills',
icon: Wrench,
id: 'nav-skills',
keywords: ['tools', 'toolsets'],
label: cc.nav.skills.title,
run: go(SKILLS_ROUTE)
},
{ icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
{ icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
{ icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
{ icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
{
action: 'nav.messaging',
icon: MessageCircle,
id: 'nav-messaging',
label: cc.nav.messaging.title,
run: go(MESSAGING_ROUTE)
},
{
action: 'nav.artifacts',
icon: Package,
id: 'nav-artifacts',
label: cc.nav.artifacts.title,
run: go(ARTIFACTS_ROUTE)
},
{
action: 'nav.cron',
icon: Clock,
id: 'nav-cron',
keywords: ['schedule', 'jobs'],
label: t.shell.statusbar.cron,
run: go(CRON_ROUTE)
},
{ action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
]
},
{
@@ -414,9 +477,7 @@ export function CommandPalette() {
title: t.settings.appearance.themeTitle,
placeholder: t.settings.appearance.themeDesc,
groups: [
// Pinned at the top: drills into the Marketplace browser. Activating an
// import only sets the skin (never the mode), so the current light/dark
// preference is preserved.
// Pinned at the top: drills into the Marketplace browser.
{
items: [
{
@@ -428,10 +489,9 @@ export function CommandPalette() {
}
]
},
// Built-ins and imported families both list under the mode(s) they
// support; picking one sets skin + mode at once. Keep the palette open
// to preview. A multi-variant import (GitHub, Solarized) appears in
// both groups and switches variants with the mode.
// Built-ins and imported families list under the mode(s) they support;
// picking sets skin + mode at once. A multi-variant import (GitHub,
// Solarized) appears in both groups and switches variants with the mode.
...(['light', 'dark'] as const).map(groupMode => ({
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
items: availableThemes
@@ -458,7 +518,6 @@ export function CommandPalette() {
{
heading: t.settings.appearance.colorMode,
items: THEME_MODES.map(entry => ({
active: mode === entry.mode,
icon: entry.icon,
id: `mode-${entry.mode}`,
keepOpen: true,
@@ -477,7 +536,7 @@ export function CommandPalette() {
groups: []
}
}),
[availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
[availableThemes, setMode, setTheme, t]
)
const activePage = page ? subPages[page] : null
@@ -508,7 +567,7 @@ export function CommandPalette() {
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"
>
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
<Command className="bg-transparent" loop>
<Command className="bg-transparent" filter={paletteFilter} loop>
{activePage && (
<button
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
@@ -553,6 +612,8 @@ export function CommandPalette() {
>
{group.items.map(item => {
const Icon = item.icon
const combo = item.action ? bindings[item.action]?.[0] : undefined
const keys = combo ? comboTokens(combo) : null
return (
<CommandItem
@@ -564,10 +625,11 @@ export function CommandPalette() {
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
{item.to ? (
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
) : (
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
{keys && <KbdGroup className="ml-auto" keys={keys} />}
{item.to && (
<ChevronRight
className={cn('size-4 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
/>
)}
</CommandItem>
)
+65 -34
View File
@@ -147,7 +147,9 @@ const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
if (a.length !== b.length) {return false}
if (a.length !== b.length) {
return false
}
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
}
@@ -223,7 +225,7 @@ export function DesktopController() {
toggleCommandCenter
} = useOverlayRouting()
const terminalTakeoverActive = chatOpen && terminalTakeover
const terminalSidebarOpen = chatOpen && terminalTakeover
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
@@ -420,7 +422,10 @@ export function DesktopController() {
const keep = sessionsToKeep(key)
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
setSessions(prev => [
...prev.filter(s => !inKey(s)),
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
])
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
@@ -681,19 +686,19 @@ export function DesktopController() {
submitText,
transcribeVoiceAudio
} = usePromptActions({
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
activeSessionId,
activeSessionIdRef,
branchCurrentSession: branchInNewChat,
busyRef,
createBackendSessionForSend,
handleSkinCommand,
refreshSessions,
requestGateway,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
updateSessionState
})
useGatewayBoot({
handleGatewayEvent: handleDesktopGatewayEvent,
@@ -719,10 +724,14 @@ export function DesktopController() {
// in the background (advancing next-run/state and creating runs), so poll the
// job list on an interval (and on tab re-focus) while connected.
useEffect(() => {
if (gatewayState !== 'open') {return}
if (gatewayState !== 'open') {
return
}
const tick = () => {
if (document.visibilityState === 'visible') {void refreshCronJobs()}
if (document.visibilityState === 'visible') {
void refreshCronJobs()
}
}
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
@@ -752,6 +761,7 @@ export function DesktopController() {
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
agentsOpen,
chatOpen,
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
@@ -790,12 +800,16 @@ export function DesktopController() {
/>
)
// One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders decide
// where it shows. Lives in main's stacking context (not the root overlay layer)
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
const mainOverlays = (
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
)
const overlays = (
<>
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
{!isSecondaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
@@ -901,12 +915,6 @@ export function DesktopController() {
/>
)
const takeoverTerminalView = (
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background) pt-(--titlebar-height)">
<TerminalSlot />
</div>
)
// Flipped layout mirrors the default: sessions sidebar → right, file
// browser + preview rail → left. Same panes, swapped sides.
const sidebarSide = panesFlipped ? 'right' : 'left'
@@ -951,18 +959,39 @@ export function DesktopController() {
</Pane>
)
const terminalPane = (
<Pane
defaultOpen
disabled={!terminalSidebarOpen}
divider
id="terminal-sidebar"
key="terminal-sidebar"
maxWidth="80vw"
minWidth="22vw"
resizable
side={railSide}
width="42vw"
>
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
<TerminalSlot />
</div>
</Pane>
)
return (
<AppShell
leftStatusbarItems={leftStatusbarItems}
leftTitlebarTools={titlebarToolGroups.flat.left}
mainOverlays={mainOverlays}
onOpenSettings={openSettings}
overlays={overlays}
previewPaneOpen={chatOpen && Boolean(previewTarget || filePreviewTarget)}
statusbarItems={statusbarItems}
terminalPaneOpen={terminalSidebarOpen}
titlebarTools={titlebarToolGroups.flat.right}
>
{!isSecondaryWindow() && (
<Pane
disabled={terminalTakeoverActive}
forceCollapsed={narrowViewport}
hoverReveal
id="chat-sidebar"
@@ -978,8 +1007,8 @@ export function DesktopController() {
)}
<PaneMain>
<Routes>
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route
element={
<Suspense fallback={null}>
@@ -1016,11 +1045,13 @@ export function DesktopController() {
</PaneMain>
{/*
Order within a side maps to column order. Default (rail on the right):
main | preview | file-browser. Flipped (rail on the left): mirror it to
file-browser | preview | main so preview stays adjacent to the chat.
main | terminal | preview | file-browser. Flipped (rail on the left):
mirror to file-browser | preview | terminal | main so terminal stays
adjacent to the chat.
*/}
{panesFlipped ? fileBrowserPane : previewPane}
{panesFlipped ? previewPane : fileBrowserPane}
{panesFlipped ? fileBrowserPane : terminalPane}
{previewPane}
{panesFlipped ? terminalPane : fileBrowserPane}
</AppShell>
)
}
+5 -5
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
@@ -103,9 +103,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
goToSession(openOrAdvanceSwitcher(direction))
}
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
const showFiles = () => {
setFileBrowserOpen(true)
setRightSidebarTab(tab)
setTerminalTakeover(false)
}
handlersRef.current = {
@@ -152,8 +152,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
toggleFileBrowserOpen()
}
},
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
'view.flipPanes': togglePanesFlipped,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
+21 -96
View File
@@ -4,22 +4,20 @@ import type { ReactNode } from 'react'
import { ErrorBoundary } from '@/components/error-boundary'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import { $currentBranch, $currentCwd } from '@/store/session'
import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
import { TerminalSlot } from './terminal/persistent'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
@@ -27,24 +25,10 @@ interface RightSidebarPaneProps {
onChangeCwd: (path: string) => Promise<void> | void
}
interface RightSidebarTab {
icon: string
id: RightSidebarTabId
labelKey: 'files' | 'terminal'
}
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
]
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
const { t } = useI18n()
const r = t.rightSidebar
const activeTab = useStore($rightSidebarTab)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
@@ -68,7 +52,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
} = useProjectTree(currentCwd)
const canCollapse = Object.values(openState).some(Boolean)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
@@ -97,8 +80,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
}
}
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
return (
<aside
aria-label={r.aria}
@@ -109,85 +90,29 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
{effectiveTab === 'terminal' ? (
<TerminalSlot />
) : (
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
)}
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
cwd={currentCwd}
cwdName={cwdName}
data={data}
error={rootError}
hasCwd={hasCwd}
loading={rootLoading}
onActivateFile={onActivateFile}
onActivateFolder={onActivateFolder}
onChangeFolder={chooseFolder}
onCollapseAll={collapseAll}
onLoadChildren={loadChildren}
onNodeOpenChange={setNodeOpen}
onPreviewFile={previewFile}
onRefresh={() => void refreshRoot()}
openState={openState}
/>
</aside>
)
}
function RightSidebarChrome({
activeTab,
branch,
tabs
}: {
activeTab: RightSidebarTabId
branch: string
tabs: readonly RightSidebarTab[]
}) {
const { t } = useI18n()
const r = t.rightSidebar
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex items-center gap-2 px-2.5 py-1">
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
{tabs.map(tab => {
const label = r[tab.labelKey]
return (
<Tip key={tab.id} label={label}>
<Button
aria-label={label}
aria-pressed={tab.id === activeTab}
className={cn(
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => setRightSidebarTab(tab.id)}
size="icon-xs"
variant="ghost"
>
<Codicon name={tab.icon} size="0.875rem" />
</Button>
</Tip>
)
})}
</nav>
{branch && (
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
<span className="truncate">{branch}</span>
</span>
)}
</div>
</header>
)
}
interface FilesystemTabProps extends FileTreeBodyProps {
canCollapse: boolean
cwdName: string
@@ -2,14 +2,10 @@ import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
@@ -0,0 +1,65 @@
import type { Terminal } from '@xterm/xterm'
// Serialized view of the in-app terminal, handed to the agent's `read_terminal`
// tool. Line indices are absolute into xterm's buffer (0 = oldest scrollback
// line), so the agent can page with start_line/count against `total_lines`.
export interface TerminalReadResult {
total_lines: number
start: number
end: number
viewport_rows: number
cursor_row: number
text: string
}
export interface TerminalReadOptions {
start?: number
count?: number
}
type Reader = (opts: TerminalReadOptions) => TerminalReadResult
// The persistent terminal is a singleton (one xterm mounted forever), so a
// module-level slot is enough — set while the session is live, cleared on
// dispose. The gateway `terminal.read.request` handler reads through this.
let activeReader: Reader | null = null
export function setActiveTerminalReader(reader: Reader | null): void {
activeReader = reader
}
export function readActiveTerminal(opts: TerminalReadOptions = {}): TerminalReadResult | null {
return activeReader ? activeReader(opts) : null
}
export function makeTerminalReader(term: Terminal): Reader {
return ({ start, count }) => {
const buf = term.buffer.active
const total = buf.length
const rows = term.rows
// Default window = the visible screen; baseY is the viewport's top row.
const from = Math.max(0, Math.min(start ?? buf.baseY, total))
const to = Math.max(from, Math.min(from + Math.max(1, count ?? rows), total))
const lines: string[] = []
// translateToString(true) right-trims and resolves wide chars, dropping SGR
// colors — exactly what the agent wants.
for (let i = from; i < to; i += 1) {
lines.push(buf.getLine(i)?.translateToString(true) ?? '')
}
while (lines.length && !lines[lines.length - 1].trim()) {
lines.pop()
}
return {
total_lines: total,
start: from,
end: to,
viewport_rows: rows,
cursor_row: buf.baseY + buf.cursorY,
text: lines.join('\n')
}
}
}
@@ -1,7 +1,5 @@
import '@xterm/xterm/css/xterm.css'
import { useStore } from '@nanostores/react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
@@ -9,7 +7,7 @@ import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
import { setTerminalTakeover } from '../store'
import { addSelectionShortcutLabel } from './selection'
import { useTerminalSession } from './use-terminal-session'
@@ -21,41 +19,32 @@ interface TerminalTabProps {
export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
const { t } = useI18n()
const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({
cwd,
onAddSelectionToChat
})
const takeover = useStore($terminalTakeover)
const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus
const toggleTakeover = () => {
// Pre-select the Terminal tab so the slot is ready to host us on return.
if (takeover) {
setRightSidebarTab('terminal')
}
setTerminalTakeover(!takeover)
}
const label = t.rightSidebar.terminalHide
return (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
<Tip label={label}>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md text-white!"
onClick={toggleTakeover}
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
onClick={() => setTerminalTakeover(false)}
size="icon"
type="button"
variant="ghost"
>
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
<Codicon name="close" size="0.875rem" />
</Button>
</Tip>
</div>
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
<div className="relative min-h-0 flex-1 bg-(--ui-editor-surface-background) p-2">
{status === 'starting' && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<Loader
@@ -84,12 +73,13 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
</Button>
</div>
)}
{/* Outer div paints the dark inset; inner div is the xterm host so the
canvas sizes to the *content* area and p-2 shows as terminal padding.
Forcing screen/viewport bg avoids xterm's default black peeking
through the unused pixels below the last full row. */}
{/* Outer div paints terminal inset; inner div is the xterm host so the
canvas sizes to the content area and p-2 stays as terminal padding.
Screen/viewport inherit the live skin surface so the terminal blends
with the app and follows light/dark; the xterm canvas itself is
painted the resolved surface color in use-terminal-session. */}
<div
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-(--ui-editor-surface-background)! [&_.xterm-viewport]:bg-(--ui-editor-surface-background)!"
ref={hostRef}
/>
</div>
@@ -2,8 +2,6 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { TERMINAL_BG } from './selection'
import { TerminalTab } from './index'
/**
@@ -107,7 +105,9 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
zIndex: 4,
backgroundColor: TERMINAL_BG,
// Match the live skin surface so the header strip (transparent) and body
// read as one cohesive pane instead of revealing a near-black slab behind.
backgroundColor: 'var(--ui-editor-surface-background)',
contain: 'layout size paint'
}
@@ -1,38 +1,101 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
// Solarized-derived palette, but with bright ANSI 815 promoted to real
// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
// We always render the dark canvas — the app's light surfaces can't host the
// default skin without dropping below readable contrast.
export const TERMINAL_BG = '#002b36'
import type { DesktopTerminalPalette } from '@/themes/types'
const THEME: ITheme = {
background: TERMINAL_BG,
foreground: '#839496',
cursor: '#93a1a1',
cursorAccent: TERMINAL_BG,
selectionBackground: '#586e7555',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#586e75',
brightRed: '#f25c54',
brightGreen: '#b3d437',
brightYellow: '#f7c948',
brightBlue: '#5fb3ff',
brightMagenta: '#ff6ab4',
brightCyan: '#5cd9c8',
brightWhite: '#fdf6e3'
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
// fixed table per theme type, not luminance-derived. Light/dark diverge on
// purpose so each stays legible (e.g. mustard yellow on white).
const DARK_THEME: ITheme = {
background: '#1e1e1e',
foreground: '#cccccc',
cursor: '#cccccc',
cursorAccent: '#1e1e1e',
selectionBackground: '#264f7866',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#e5e5e5'
}
export const terminalTheme = (): ITheme => THEME
const LIGHT_THEME: ITheme = {
background: '#ffffff',
foreground: '#333333',
cursor: '#333333',
cursorAccent: '#ffffff',
selectionBackground: '#add6ff80',
black: '#000000',
red: '#cd3131',
green: '#00bc00',
yellow: '#949800',
blue: '#0451a5',
magenta: '#bc05bc',
cyan: '#0598bc',
white: '#555555',
brightBlack: '#666666',
brightRed: '#cd3131',
brightGreen: '#14ce14',
brightYellow: '#b5ba00',
brightBlue: '#0451a5',
brightMagenta: '#bc05bc',
brightCyan: '#0598bc',
brightWhite: '#a5a5a5'
}
// Palette by painted mode, optionally overlaid with an imported theme's ANSI
// palette (Solarized terminal for the Solarized skin, etc.). `palette` only
// fills the slots it defines, so a partial import keeps the mode defaults for
// the rest. `background` is a fallback only — withSurface swaps in the live skin
// surface at runtime (keeping transparency); minimumContrastRatio keeps colors
// crisp against it.
export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme {
const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME
if (!palette) {
return base
}
const overlay = { ...base } as Record<string, string>
for (const [slot, value] of Object.entries(palette)) {
if (value) {
overlay[slot] = value
}
}
return overlay as ITheme
}
// Resolve --ui-editor-surface-background (a color-mix on the skin seed) to a
// concrete rgb for the WebGL renderer + contrast clamp. Custom props don't
// resolve via getComputedStyle, so probe a real background-color. Read AFTER
// applyTheme repaints (mount / rAF post-change) or it lags a frame behind.
export function resolveSurfaceColor(fallback: string): string {
if (typeof document === 'undefined' || !document.body) {
return fallback
}
const probe = document.createElement('span')
probe.style.cssText =
'position:absolute;visibility:hidden;pointer-events:none;background-color:var(--ui-editor-surface-background)'
document.body.appendChild(probe)
const resolved = getComputedStyle(probe).backgroundColor
probe.remove()
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
}
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
@@ -3,12 +3,20 @@ import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl'
import { Terminal } from '@xterm/xterm'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
import {
isAddSelectionShortcut,
resolveSurfaceColor,
terminalSelectionAnchor,
terminalSelectionLabel,
terminalTheme
} from './selection'
type TerminalStatus = 'closed' | 'open' | 'starting'
@@ -64,10 +72,29 @@ function stripEscapeSequences(data: string) {
return text
}
function isStartupSpacer(data: string) {
const text = stripEscapeSequences(data).replace(/[\s\r\n]/g, '')
// Keep only the ANSI escape sequences from a chunk, dropping printable text. Lets
// us apply control codes (e.g. a clear-screen) while discarding boot spacers and
// zsh's reverse-video "%" partial-line marker.
function keepEscapeSequences(data: string) {
let index = 0
let out = ''
return text === '' || text === '%'
while (index < data.length) {
if (data.charCodeAt(index) === 0x1b) {
const sequence = readEscapeSequence(data, index)
if (sequence) {
out += sequence
index += sequence.length
continue
}
}
index += 1
}
return out
}
function stripInitialPromptGap(data: string) {
@@ -95,6 +122,14 @@ interface UseTerminalSessionOptions {
onAddSelectionToChat: (text: string, label?: string) => void
}
// Bind the palette to the live skin surface so the terminal blends with the app
// (and the contrast clamp has a real background to work against).
function withSurface(theme: ReturnType<typeof terminalTheme>) {
const surface = resolveSurfaceColor(theme.background ?? '#ffffff')
return { ...theme, background: surface, cursorAccent: surface }
}
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) {
return true
@@ -184,8 +219,21 @@ function quotePathForShell(path: string, shellName: string): string {
}
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
// Key off renderedMode (the painted surface type), not resolvedMode (the
// clicked switch) — a skin can keep a light surface in "dark" mode, and we
// must match the surface or the ANSI palette inverts against it. themeName
// re-resolves the canvas surface on skin switches (same mode, new tint).
const { renderedMode, theme, themeName } = useTheme()
// Adopt the skin's ANSI palette when it ships one (imported VS Code themes do),
// matched to the painted variant; built-in skins carry none, so the terminal
// keeps its VS Code defaults. withSurface still owns the background, so this
// never touches transparency.
const ansiPalette = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal
const activeTheme = useMemo(() => terminalTheme(renderedMode, ansiPalette), [renderedMode, ansiPalette])
const initialThemeRef = useRef(activeTheme)
const hostRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const webglRef = useRef<WebglAddon | null>(null)
const sessionIdRef = useRef<string | null>(null)
const shellNameRef = useRef('shell')
const selectionLabelRef = useRef('')
@@ -200,19 +248,26 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
onAddSelectionToChatRef.current = onAddSelectionToChat
}, [onAddSelectionToChat])
// Live selection at call time. A redraw-heavy TUI (spinners, clocks) outruns
// onSelectionChange, so trust xterm directly — fall back to the native
// selection — rather than the cached ref / React state.
const readSelection = useCallback(
() => termRef.current?.getSelection() || window.getSelection()?.toString() || '',
[]
)
const addSelectionToChat = useCallback(() => {
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
const selectedText = readSelection() || selectionRef.current
const trimmed = selectedText.trim()
if (!trimmed) {
return
}
const label =
selectionLabelRef.current ||
(termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection')
onAddSelectionToChatRef.current(trimmed, label)
termRef.current?.clearSelection()
selectionRef.current = ''
@@ -220,15 +275,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
setSelection('')
setSelectionStyle(null)
triggerHaptic('selection')
}, [])
}, [readSelection])
// Always listen — gating on the React selection state misses selections the
// TUI redraw races. Only swallow ⌘/Ctrl+L when there's text to send, else it
// must reach the shell as clear-screen.
useEffect(() => {
if (!selection.trim()) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (!isAddSelectionShortcut(event)) {
if (!isAddSelectionShortcut(event) || !readSelection().trim()) {
return
}
@@ -240,7 +294,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [addSelectionToChat, selection])
}, [addSelectionToChat, readSelection])
useEffect(() => {
const host = hostRef.current
@@ -264,9 +318,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
fontFamily: "'SF Mono', 'Menlo', 'Cascadia Code', 'JetBrains Mono', monospace",
fontSize: 11,
lineHeight: 1.12,
// Full-screen TUIs (hermes --tui, vim) grab the mouse, so a plain drag
// can't select — ⌥-drag (macOS) / Shift-drag (else) forces a native
// selection over mouse-mode apps, which ⌘/Ctrl+L then sends to chat.
macOptionClickForcesSelection: true,
macOptionIsMeta: true,
// VS Code/Cursor's secret sauce: terminal.integrated.minimumContrastRatio
// defaults to 4.5 there. xterm defaults to 1 (off), which paints the raw
// saturated ANSI palette — vivid green/cyan on white reads as candy.
// Clamping to 4.5:1 darkens/lightens foregrounds against the background
// at render time, matching the muted ink-like look of their terminal.
minimumContrastRatio: 4.5,
scrollback: 1000,
theme: terminalTheme()
theme: withSurface(initialThemeRef.current)
})
const fit = new FitAddon()
@@ -276,18 +340,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.loadAddon(new Unicode11Addon())
term.loadAddon(new WebLinksAddon())
term.unicode.activeVersion = '11'
term.open(host)
term.focus()
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
// renderer paints SGR via CSS classes that visibly mute against our skins.
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => webgl.dispose())
term.loadAddon(webgl)
} catch (err) {
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
// Let the GUI chat agent read this pane via the `read_terminal` tool: the
// gateway's terminal.read.request handler serializes the buffer through this.
setActiveTerminalReader(makeTerminalReader(term))
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
@@ -328,6 +384,75 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
host.removeEventListener('drop', onDrop)
})
// A fresh prompt should sit at the top. Every resize SIGWINCHes the shell,
// which reprints its prompt and can leave stale blank rows above it. While
// the session is pristine (nothing run yet) we ask the shell to clear +
// redraw via Ctrl-L (\f) after the resize settles. Ctrl-L preserves
// multi-line prompts (term.clear() would drop all but the cursor row) and we
// stop the moment real output exists, so command scrollback is never wiped.
let promptPristine = true
let gapCleanupTimer = 0
// While armed, strip leading blank rows so the prompt lands at the very top
// (no starship `add_newline` gap). Re-armed before each Ctrl-L redraw so the
// resize cleanup doesn't reintroduce the blank line.
let stripLeading = true
const armedWrite = (data: string) => {
if (!stripLeading) {
term.write(data)
return
}
const next = stripInitialPromptGap(data)
const visible = stripEscapeSequences(next).replace(/[\s%]/g, '')
if (!visible) {
// Spacer / lone clear-screen / zsh `%` marker: apply control codes but
// drop the blank text and stay armed so the prompt still lands at top.
const controls = keepEscapeSequences(next)
if (controls) {
term.write(controls)
}
return
}
stripLeading = false
term.write(next)
}
const scheduleGapCleanup = () => {
if (!promptPristine) {
return
}
if (gapCleanupTimer) {
window.clearTimeout(gapCleanupTimer)
}
gapCleanupTimer = window.setTimeout(() => {
gapCleanupTimer = 0
const id = sessionIdRef.current
if (disposed || !id || !promptPristine) {
return
}
stripLeading = true
void terminalApi.write(id, '\f')
term.clearSelection()
}, 120)
}
cleanup.push(() => {
if (gapCleanupTimer) {
window.clearTimeout(gapCleanupTimer)
}
})
const fitAndResize = () => {
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
return
@@ -344,6 +469,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
if (id && (lastSentSize?.cols !== term.cols || lastSentSize?.rows !== term.rows)) {
lastSentSize = { cols: term.cols, rows: term.rows }
void terminalApi.resize(id, { cols: term.cols, rows: term.rows })
scheduleGapCleanup()
}
}
@@ -380,6 +506,12 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const id = sessionIdRef.current
if (id) {
// Once the user submits a line, real output may follow — stop the
// pristine-prompt gap cleanup so we never clear command scrollback.
if (promptPristine && data.includes('\r')) {
promptPristine = false
}
void terminalApi.write(id, data)
}
})
@@ -396,87 +528,88 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
cleanup.push(() => selectionDisposable.dispose())
term.attachCustomKeyEventHandler(event => {
if (event.type !== 'keydown') {
return true
}
const startSession = () =>
void terminalApi
.start({ cols: term.cols, cwd, rows: term.rows })
.then(session => {
if (disposed) {
void terminalApi.dispose(session.id)
if (isAddSelectionShortcut(event) && term.hasSelection()) {
event.preventDefault()
addSelectionToChat()
return
}
return false
}
sessionIdRef.current = session.id
lastSentSize = { cols: term.cols, rows: term.rows }
shellNameRef.current = session.shell || 'shell'
setShellName(session.shell || 'shell')
return true
})
const initial = term.hasSelection() ? term.getSelection() : ''
selectionRef.current = initial
selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : ''
fitAndResize()
setStatus('open')
void terminalApi
.start({ cols: term.cols, cwd, rows: term.rows })
.then(session => {
if (disposed) {
void terminalApi.dispose(session.id)
cleanup.push(
terminalApi.onData(session.id, armedWrite),
terminalApi.onExit(session.id, ({ code, signal }) => {
setStatus('closed')
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
})
)
return
}
sessionIdRef.current = session.id
lastSentSize = { cols: term.cols, rows: term.rows }
shellNameRef.current = session.shell || 'shell'
setShellName(session.shell || 'shell')
if (term.hasSelection()) {
const currentSelection = term.getSelection()
selectionRef.current = currentSelection
selectionLabelRef.current = terminalSelectionLabel(term, shellNameRef.current, currentSelection)
} else {
selectionRef.current = ''
selectionLabelRef.current = ''
}
setStatus('open')
let wrotePromptContent = false
cleanup.push(
terminalApi.onData(session.id, data => {
if (wrotePromptContent) {
term.write(data)
return
}
if (isStartupSpacer(data)) {
return
}
const next = stripInitialPromptGap(data)
if (next) {
wrotePromptContent = true
term.write(next)
}
}),
terminalApi.onExit(session.id, sessionExit => {
const { code, signal } = sessionExit
setStatus('closed')
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
window.requestAnimationFrame(() => {
fitAndResize()
term.clearSelection() // drop any selection painted over transient boot rows
term.focus()
})
)
window.requestAnimationFrame(() => {
fitAndResize()
term.focus()
})
})
.catch(error => {
setStatus('closed')
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
})
.catch(error => {
setStatus('closed')
term.write(`Terminal failed to start: ${error instanceof Error ? error.message : String(error)}\r\n`)
})
// Open + fit + start only once webfonts settle. Fitting with fallback metrics
// picks the wrong row count, the shell boots at that size, then the real font
// loads -> refit -> SIGWINCH -> the shell reprints its prompt lower, leaving
// stale blank rows (and a stray selection) above it.
const mount = () => {
if (disposed || !host.isConnected) {
return
}
term.open(host)
term.focus()
// WebGL renderer matches the dashboard ChatPage path; xterm's default DOM
// renderer paints SGR via CSS classes that visibly mute against our skins.
try {
const webgl = new WebglAddon()
webgl.onContextLoss(() => {
webgl.dispose()
webglRef.current = null
})
term.loadAddon(webgl)
webglRef.current = webgl
} catch (err) {
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
fitAndResize()
startSession()
}
const fonts = typeof document !== 'undefined' ? document.fonts : undefined
if (fonts?.ready) {
void fonts.ready.then(mount, mount)
} else {
mount()
}
return () => {
disposed = true
cleanup.forEach(run => run())
setActiveTerminalReader(null)
const id = sessionIdRef.current
sessionIdRef.current = null
@@ -487,12 +620,34 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.dispose()
termRef.current = null
webglRef.current = null
shellNameRef.current = 'shell'
selectionRef.current = ''
selectionLabelRef.current = ''
}
}, [addSelectionToChat, cwd])
useEffect(() => {
const term = termRef.current
if (!term) {
return
}
// Re-resolve the surface in a rAF: ThemeProvider's applyTheme repaints the
// CSS vars in a sibling effect that runs after this one, so reading now
// would lag a mode behind. By the next frame the vars are current.
const raf = requestAnimationFrame(() => {
term.options.theme = withSurface(activeTheme)
// The WebGL renderer caches glyph colors in a texture atlas, so a
// light/dark switch leaves already-drawn cells stale until the atlas is
// cleared. No-op for the DOM fallback.
webglRef.current?.clearTextureAtlas()
})
return () => cancelAnimationFrame(raf)
}, [activeTheme, themeName])
return {
addSelectionToChat,
hostRef,
@@ -1,6 +1,7 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
import {
appendAssistantTextPart,
appendReasoningPart,
@@ -18,6 +19,7 @@ import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
@@ -906,6 +908,21 @@ export function useMessageStream({
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
}
}
} else if (event.type === 'terminal.read.request') {
// read_terminal tool: serialize the renderer's xterm buffer and answer
// immediately (Python blocks on the respond). Empty text = no live pane.
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
if (requestId) {
const start = typeof payload?.start === 'number' ? payload.start : undefined
const count = typeof payload?.count === 'number' ? payload.count : undefined
const result = readActiveTerminal({ start, count })
void $gateway.get()?.request('terminal.read.respond', {
request_id: requestId,
text: result ? JSON.stringify(result) : ''
})
}
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
+27 -4
View File
@@ -29,9 +29,19 @@ interface AppShellProps {
children: ReactNode
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
// Fixed-position overlays that must share <main>'s stacking context so pane
// resize handles (z-20) paint above them. The persistent terminal lives here:
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
// would cover every pane's drag handle.
mainOverlays?: ReactNode
onOpenSettings: () => void
overlays?: ReactNode
// Rails that sit at the window's left edge in the flipped layout but never
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
// lights (and zero the titlebar inset) even below the collapse breakpoint.
previewPaneOpen?: boolean
statusbarItems?: readonly StatusbarItem[]
terminalPaneOpen?: boolean
titlebarTools?: readonly TitlebarTool[]
}
@@ -54,9 +64,12 @@ export function AppShell({
children,
leftStatusbarItems,
leftTitlebarTools,
mainOverlays,
onOpenSettings,
overlays,
previewPaneOpen = false,
statusbarItems,
terminalPaneOpen = false,
titlebarTools
}: AppShellProps) {
const sidebarOpen = useStore($sidebarOpen)
@@ -76,12 +89,17 @@ export function AppShell({
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead. Below the collapse
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
// edge is uncovered regardless of their stored open state. A standalone
// Flipped layout: the file browser does instead. Both force-collapse to a
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
// is uncovered there regardless of their stored open state. A standalone
// session window renders no sidebar at all, so its edge is always uncovered.
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
// The terminal + preview rails never force-collapse, so when they're the
// leftmost open pane (flipped layout) they cover the edge even when narrow.
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
const leftEdgePaneOpen =
!narrowViewport && !isSecondaryWindow() && (panesFlipped ? fileBrowserOpen : sidebarOpen)
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
const titlebarContentInset = leftEdgePaneOpen
? 0
@@ -160,6 +178,11 @@ export function AppShell({
{children}
</PaneShell>
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
after PaneShell so it paints over pane content, but its z stays under
the panes' z-20 resize handles, keeping every pane resizable. */}
{mainOverlays}
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
</main>
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { useI18n } from '@/i18n'
import {
@@ -14,6 +15,7 @@ import {
Hash,
Loader2,
Sparkles,
Terminal,
Zap,
ZapFilled
} from '@/lib/icons'
@@ -56,6 +58,7 @@ import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-contr
interface StatusbarItemsOptions {
agentsOpen: boolean
chatOpen: boolean
commandCenterOpen: boolean
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
@@ -73,6 +76,7 @@ interface StatusbarItemsOptions {
export function useStatusbarItems({
agentsOpen,
chatOpen,
commandCenterOpen,
extraLeftItems,
extraRightItems,
@@ -90,6 +94,7 @@ export function useStatusbarItems({
const { t } = useI18n()
const copy = t.shell.statusbar
const activeSessionId = useStore($activeSessionId)
const terminalTakeover = useStore($terminalTakeover)
const yoloActive = useStore($yoloActive)
const busy = useStore($busy)
const currentFastMode = useStore($currentFastMode)
@@ -442,11 +447,21 @@ export function useStatusbarItems({
variant: 'action' as const
})
},
{
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
hidden: !chatOpen,
icon: <Terminal className="size-3.5" />,
id: 'terminal',
onSelect: () => setTerminalTakeover(!$terminalTakeover.get()),
title: terminalTakeover ? copy.hideTerminal : copy.showTerminal,
variant: 'action'
},
clientVersionItem,
...(backendVersionItem ? [backendVersionItem] : [])
],
[
busy,
chatOpen,
contextBar,
contextUsage,
copy,
@@ -457,6 +472,7 @@ export function useStatusbarItems({
modelMenuContent,
sessionStartedAt,
showYoloToggle,
terminalTakeover,
toggleYolo,
turnStartedAt,
clientVersionItem,
@@ -30,6 +30,8 @@ export interface PaneProps {
children?: ReactNode
className?: string
defaultOpen?: boolean
/** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
divider?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
@@ -94,19 +96,35 @@ const remPx = () =>
? 16
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to
// pixels for drag clamping. Viewport units resolve against the current window width.
function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|%)?$/)
if (!match) {
return undefined
}
return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
const n = Number.parseFloat(match[1])
switch (match[2]) {
case 'rem':
return n * remPx()
case 'vw':
case '%':
return (n * viewportPx()) / 100
default:
return n
}
}
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
@@ -217,6 +235,7 @@ export function Pane({
children,
className,
defaultOpen = true,
divider = false,
disabled = false,
hoverReveal = false,
id,
@@ -409,6 +428,7 @@ export function Pane({
role="separator"
tabIndex={0}
>
{divider && <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary)" />}
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
</div>
)}
+4 -3
View File
@@ -1151,7 +1151,7 @@ export const en: Translations = {
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
steer: 'Steer the current run (⌘⏎)',
steer: 'Steer the current run',
stop: 'Stop',
send: 'Send',
speaking: 'Speaking',
@@ -1492,6 +1492,8 @@ export const en: Translations = {
branch: branch => `branch ${branch}`,
closeCommandCenter: 'Close Command Center',
openCommandCenter: 'Open Command Center',
showTerminal: 'Show terminal',
hideTerminal: 'Hide terminal',
gateway: 'Gateway',
gatewayReady: 'ready',
gatewayNeedsSetup: 'needs setup',
@@ -1547,8 +1549,7 @@ export const en: Translations = {
tryAgain: 'Try again',
loadingTree: 'Loading file tree',
loadingFiles: 'Loading files',
terminalFocus: 'Focus terminal view',
terminalSplit: 'Return to split view',
terminalHide: 'Hide terminal',
addToChat: 'Add to chat'
},
+3 -2
View File
@@ -1625,6 +1625,8 @@ export const ja = defineLocale({
branch: branch => `ブランチ ${branch}`,
closeCommandCenter: 'コマンドセンターを閉じる',
openCommandCenter: 'コマンドセンターを開く',
showTerminal: 'ターミナルを表示',
hideTerminal: 'ターミナルを非表示',
gateway: 'ゲートウェイ',
gatewayReady: '準備完了',
gatewayNeedsSetup: '設定が必要',
@@ -1680,8 +1682,7 @@ export const ja = defineLocale({
tryAgain: '再試行',
loadingTree: 'ファイルツリーを読み込み中',
loadingFiles: 'ファイルを読み込み中',
terminalFocus: 'ターミナルビューにフォーカス',
terminalSplit: '分割ビューに戻る',
terminalHide: 'ターミナルを非表示',
addToChat: 'チャットに追加'
},
+3 -2
View File
@@ -1154,6 +1154,8 @@ export interface Translations {
branch: (branch: string) => string
closeCommandCenter: string
openCommandCenter: string
showTerminal: string
hideTerminal: string
gateway: string
gatewayReady: string
gatewayNeedsSetup: string
@@ -1209,8 +1211,7 @@ export interface Translations {
tryAgain: string
loadingTree: string
loadingFiles: string
terminalFocus: string
terminalSplit: string
terminalHide: string
addToChat: string
}
+3 -2
View File
@@ -1586,6 +1586,8 @@ export const zhHant = defineLocale({
branch: branch => `分支 ${branch}`,
closeCommandCenter: '關閉命令中心',
openCommandCenter: '開啟命令中心',
showTerminal: '顯示終端機',
hideTerminal: '隱藏終端機',
gateway: '閘道',
gatewayReady: '就緒',
gatewayNeedsSetup: '需要設定',
@@ -1641,8 +1643,7 @@ export const zhHant = defineLocale({
tryAgain: '重試',
loadingTree: '正在載入檔案樹',
loadingFiles: '正在載入檔案',
terminalFocus: '聚焦終端機檢視',
terminalSplit: '返回分割檢視',
terminalHide: '隱藏終端機',
addToChat: '新增至聊天'
},
+4 -3
View File
@@ -1337,7 +1337,7 @@ export const zh: Translations = {
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
steer: '引导当前运行 (⌘⏎)',
steer: '引导当前运行',
stop: '停止',
send: '发送',
speaking: '讲话中',
@@ -1672,6 +1672,8 @@ export const zh: Translations = {
branch: branch => `分支 ${branch}`,
closeCommandCenter: '关闭命令中心',
openCommandCenter: '打开命令中心',
showTerminal: '显示终端',
hideTerminal: '隐藏终端',
gateway: '网关',
gatewayReady: '就绪',
gatewayNeedsSetup: '需要设置',
@@ -1727,8 +1729,7 @@ export const zh: Translations = {
tryAgain: '重试',
loadingTree: '正在加载文件树',
loadingFiles: '正在加载文件',
terminalFocus: '聚焦终端视图',
terminalSplit: '返回分栏视图',
terminalHide: '隐藏终端',
addToChat: '添加到对话'
},
+3
View File
@@ -61,6 +61,9 @@ export type GatewayEventPayload = {
// secret.request (skill credential capture)
env_var?: string
prompt?: string
// terminal.read.request (GUI agent reading the in-app terminal pane)
start?: number
count?: number
}
export function textPart(text: string): ChatMessagePart {
+7 -8
View File
@@ -13,13 +13,7 @@ export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel'
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
// layout, appearance, and the panel-opener.
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = [
'composer',
'profiles',
'session',
'navigation',
'view'
]
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = ['composer', 'profiles', 'session', 'navigation', 'view']
export interface KeybindActionMeta {
id: string
@@ -43,6 +37,11 @@ const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE
defaults: [comboForSlot(i + 1)]
}))
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
// `mod` keeps one binding cross-platform; on macOS this shadows the system
// window-cycler, which is fine for a single-window app.
const TERMINAL_TOGGLE_DEFAULTS = ['mod+`', 'mod+shift+`']
// Positional jumps — ^1…^9, mirroring profiles' ⌘1…⌘9.
export const SESSION_SLOT_COUNT = 9
@@ -90,7 +89,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
{ id: 'view.showFiles', category: 'view', defaults: [] },
{ id: 'view.showTerminal', category: 'view', defaults: [] },
{ id: 'view.showTerminal', category: 'view', defaults: TERMINAL_TOGGLE_DEFAULTS },
// ⌘\ — the backslash reads like a mirror line flipping the layout.
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
+32 -28
View File
@@ -10,8 +10,7 @@
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`.
export const IS_MAC =
typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
@@ -140,33 +139,38 @@ function labelForBase(base: string): string {
return base.length === 1 ? base.toUpperCase() : base
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
function labelForMod(mod: string): string {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
if (mod === 'ctrl') {
return IS_MAC ? '⌃' : 'Ctrl'
}
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
return mod
}
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
// one cap per token for <KbdGroup>.
export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
const mods = parts
const modLabels = mods.map(mod => {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
return [...parts.map(labelForMod), labelForBase(base)]
}
if (mod === 'ctrl') {
return IS_MAC ? '⌃' : 'Ctrl'
}
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
return mod
})
const tokens = [...modLabels, labelForBase(base)]
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
const tokens = comboTokens(combo)
return IS_MAC ? tokens.join('') : tokens.join('+')
}
@@ -178,9 +182,9 @@ export function isEditableTarget(target: EventTarget | null): boolean {
return Boolean(
el?.isContentEditable ||
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
)
}
+17 -2
View File
@@ -252,7 +252,15 @@ interface ThemeContextValue {
theme: DesktopTheme
themeName: string
mode: ThemeMode
/** The light/dark switch the user picked. */
resolvedMode: 'light' | 'dark'
/**
* The mode actually painted, derived from the active background's luminance.
* Differs from `resolvedMode` for skins that keep a bright surface in "dark"
* (or vice-versa). Surface-bound UI (e.g. the terminal palette) should key off
* this so it matches what's on screen instead of inverting.
*/
renderedMode: 'light' | 'dark'
availableThemes: Array<{ name: string; label: string; description: string }>
setTheme: (name: string) => void
setMode: (mode: ThemeMode) => void
@@ -265,6 +273,7 @@ const ThemeContext = createContext<ThemeContextValue>({
themeName: DEFAULT_SKIN_NAME,
mode: 'light',
resolvedMode: 'light',
renderedMode: 'light',
availableThemes: SKIN_LIST,
setTheme: () => {},
setMode: () => {}
@@ -310,6 +319,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
// What actually gets painted (matches the `.dark` class applyTheme toggles).
const renderedMode = useMemo(
() => renderedModeFor(activeTheme.colors, resolvedMode),
[activeTheme, resolvedMode]
)
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
// Assign to whichever profile is live right now (read fresh so the callbacks
@@ -331,8 +346,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable.
const value = useMemo<ThemeContextValue>(
() => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes, setTheme, setMode }),
[activeTheme, themeName, mode, resolvedMode, availableThemes, setTheme, setMode]
() => ({ theme: activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode }),
[activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode]
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
+14 -6
View File
@@ -48,19 +48,27 @@ export function buildThemeFromMarketplace(result: DesktopMarketplaceThemeResult)
const label = file.label || raw.name || result.displayName
const { mode, theme } = convertVscodeColorTheme(raw, { label, source: result.extensionId })
return { mode, palette: theme.colors }
return { mode, palette: theme.colors, terminal: theme.terminal }
})
const fallback = variants[0].palette
const light = variants.find(variant => variant.mode === 'light')?.palette
const dark = variants.find(variant => variant.mode === 'dark')?.palette
const fallback = variants[0]
const light = variants.find(variant => variant.mode === 'light') ?? fallback
const dark = variants.find(variant => variant.mode === 'dark') ?? fallback
// The terminal ANSI palette tracks the painted variant the same way colors do
// (light → terminal, dark → darkTerminal); each falls back to the other so a
// single-variant import still themes the terminal in both modes.
const terminal = light.terminal ?? dark.terminal
const darkTerminal = dark.terminal ?? light.terminal
return {
name: vscodeThemeSlug(result.displayName),
label: result.displayName,
description: `VS Code · ${result.extensionId}`,
colors: light ?? dark ?? fallback,
darkColors: dark ?? light ?? fallback
colors: light.palette,
darkColors: dark.palette,
...(terminal ? { terminal } : {}),
...(darkTerminal ? { darkTerminal } : {})
}
}
+35
View File
@@ -54,6 +54,37 @@ export interface DesktopThemeTypography {
fontUrl?: string
}
/**
* Integrated-terminal ANSI palette (xterm `ITheme`, minus `background`).
*
* Populated only when a converted VS Code theme ships a full `terminal.ansi*`
* set; otherwise the terminal keeps its built-in VS Code default palette.
* `background` is intentionally absent — the pane always paints the live skin
* surface so it stays translucent.
*/
export interface DesktopTerminalPalette {
foreground?: string
cursor?: string
/** Keeps its source alpha — xterm blends it over the surface. */
selectionBackground?: string
black?: string
red?: string
green?: string
yellow?: string
blue?: string
magenta?: string
cyan?: string
white?: string
brightBlack?: string
brightRed?: string
brightGreen?: string
brightYellow?: string
brightBlue?: string
brightMagenta?: string
brightCyan?: string
brightWhite?: string
}
export interface DesktopTheme {
name: string
label: string
@@ -63,4 +94,8 @@ export interface DesktopTheme {
/** Hand-tuned dark palette. Skins like `nous` ship one. */
darkColors?: DesktopThemeColors
typography?: Partial<DesktopThemeTypography>
/** Light-variant terminal ANSI palette (also the fallback for dark). */
terminal?: DesktopTerminalPalette
/** Dark-variant terminal ANSI palette. Falls back to `terminal`. */
darkTerminal?: DesktopTerminalPalette
}
+85 -2
View File
@@ -16,7 +16,7 @@
*/
import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'
import type { DesktopTheme, DesktopThemeColors } from './types'
import type { DesktopTerminalPalette, DesktopTheme, DesktopThemeColors } from './types'
// Section headers / sidebar labels render in --theme-primary directly on the
// sidebar surface as small (~10px) uppercase text, so the accent has to clear
@@ -101,6 +101,85 @@ const isDarkType = (raw: VscodeColorTheme, background: string): boolean => {
return luminance(background) < 0.4
}
// xterm ITheme ANSI slots ← VS Code `terminal.ansi*` tokens. Background is
// deliberately excluded — the pane keeps the live skin surface (transparency).
const ANSI_TOKENS: ReadonlyArray<readonly [keyof DesktopTerminalPalette, string]> = [
['black', 'terminal.ansiBlack'],
['red', 'terminal.ansiRed'],
['green', 'terminal.ansiGreen'],
['yellow', 'terminal.ansiYellow'],
['blue', 'terminal.ansiBlue'],
['magenta', 'terminal.ansiMagenta'],
['cyan', 'terminal.ansiCyan'],
['white', 'terminal.ansiWhite'],
['brightBlack', 'terminal.ansiBrightBlack'],
['brightRed', 'terminal.ansiBrightRed'],
['brightGreen', 'terminal.ansiBrightGreen'],
['brightYellow', 'terminal.ansiBrightYellow'],
['brightBlue', 'terminal.ansiBrightBlue'],
['brightMagenta', 'terminal.ansiBrightMagenta'],
['brightCyan', 'terminal.ansiBrightCyan'],
['brightWhite', 'terminal.ansiBrightWhite']
]
const BASE_ANSI: ReadonlyArray<keyof DesktopTerminalPalette> = [
'black',
'red',
'green',
'yellow',
'blue',
'magenta',
'cyan',
'white'
]
const HEX_RE = /^#[0-9a-f]{3,8}$/i
/**
* Lift a theme's integrated-terminal ANSI palette, if it ships one.
*
* All-or-nothing on the base-8 colors: a half-filled palette mixed with our
* defaults reads worse than just keeping the defaults, so we adopt the theme's
* palette only when the full base set is present. ANSI slots flatten alpha over
* the editor background; selection keeps its alpha so xterm can blend it.
*/
function extractTerminalPalette(colors: Record<string, unknown>, background: string): DesktopTerminalPalette | undefined {
const hex = (key: string): string | undefined =>
normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, background) ?? undefined
const palette: DesktopTerminalPalette = {}
for (const [slot, token] of ANSI_TOKENS) {
const value = hex(token)
if (value) {
palette[slot] = value
}
}
if (!BASE_ANSI.every(slot => palette[slot])) {
return undefined
}
const foreground = hex('terminal.foreground')
const cursor = hex('terminalCursor.foreground') ?? hex('terminalCursor.background')
const selection = typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''
if (foreground) {
palette.foreground = foreground
}
if (cursor) {
palette.cursor = cursor
}
if (HEX_RE.test(selection)) {
palette.selectionBackground = selection
}
return palette
}
/** First normalizable hex among `keys`, composited over `backdrop`. */
const pick = (
colors: Record<string, unknown>,
@@ -242,6 +321,7 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
const label = (opts.label ?? raw.name ?? 'VS Code Theme').trim()
const slug = opts.slug ?? vscodeThemeSlug(label)
const terminal = extractTerminalPalette(colors, background)
return {
derived,
@@ -254,7 +334,10 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
// that have both a light and dark variant (a Marketplace extension family)
// recombine them into proper colors/darkColors via buildThemeFromMarketplace.
colors: palette,
darkColors: palette
darkColors: palette,
// Only set when the theme ships a full ANSI palette — the terminal keeps
// its built-in VS Code defaults otherwise.
...(terminal ? { terminal } : {})
}
}
}