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,