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:
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 8–15 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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
|
||||
@@ -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: 'チャットに追加'
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '新增至聊天'
|
||||
},
|
||||
|
||||
|
||||
@@ -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: '添加到对话'
|
||||
},
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user