feat(desktop): persistent terminal pane + fullscreen takeover

Adds a VSCode-style "focus terminal" toggle to the right sidebar's Terminal
tab that takes over the chat pane area without unmounting the shell. The
xterm host is mounted once at the layout root and CSS-overlayed onto
whichever <TerminalSlot /> is currently active, so the PTY session,
scrollback, selection, focus, and WebGL renderer survive every toggle.

Also:
- WebGL renderer (matching dashboard ChatPage) so Hermes' TUI skins paint
  faithfully instead of muting through xterm's default DOM renderer
- File drag/drop from the project tree or OS into xterm — paths are
  shell-quoted (zsh/bash/pwsh/cmd) and written straight into the PTY
- Solarized dark canvas with brights promoted to real accent variants
  (Schoonover's UI-gray brights washed out every TUI accent)
- Strip NO_COLOR/FORCE_COLOR/COLORFGBG/TERM=dumb leaking from non-tty
  parents (CI runners, Cursor's agent shell) so the embedded shell gets
  truecolor regardless of how Electron was launched
- rAF-debounced ResizeObserver — running fit.fit() synchronously during
  sibling pane transitions crashed the WebGL texture-atlas rebuild
This commit is contained in:
Brooklyn Nicholson 2026-05-18 02:20:03 -05:00
parent bed626bdb2
commit fd256b0a70
10 changed files with 348 additions and 90 deletions

View file

@ -3096,7 +3096,15 @@ function terminalShellEnv() {
}
}
env.COLORTERM = env.COLORTERM || 'truecolor'
// Strip color/theme-detection vars that ride along when Electron is launched
// from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0
// /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into
// light-mode). Our PTY is a real xterm-compat terminal — force truecolor.
delete env.NO_COLOR
delete env.FORCE_COLOR
delete env.COLORFGBG
env.COLORTERM = 'truecolor'
env.LC_CTYPE = env.LC_CTYPE || 'UTF-8'
env.TERM = 'xterm-256color'
env.TERM_PROGRAM = 'Hermes'

View file

@ -63,6 +63,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View file

@ -54,6 +54,8 @@ import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { ModelPickerOverlay } from './model-picker-overlay'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
@ -99,6 +101,7 @@ export function DesktopController() {
const filePreviewTarget = useStore($filePreviewTarget)
const previewTarget = useStore($previewTarget)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const terminalTakeover = useStore($terminalTakeover)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
@ -118,6 +121,7 @@ export function DesktopController() {
settingsOpen,
toggleCommandCenter
} = useOverlayRouting()
const terminalTakeoverActive = chatOpen && terminalTakeover
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
@ -466,6 +470,9 @@ export function DesktopController() {
const overlays = (
<>
<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} />
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
@ -548,6 +555,12 @@ 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>
)
return (
<AppShell
commandCenterOpen={commandCenterOpen}
@ -563,6 +576,7 @@ export function DesktopController() {
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
disabled={terminalTakeoverActive}
resizable
side="left"
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
@ -571,8 +585,8 @@ export function DesktopController() {
</Pane>
<PaneMain>
<Routes>
<Route element={chatView} index />
<Route element={chatView} path=":sessionId" />
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
<Route
element={
<Suspense fallback={null}>
@ -650,7 +664,6 @@ export function DesktopController() {
<RightSidebarPane
onActivateFile={composer.attachContextFilePath}
onActivateFolder={composer.attachContextFolderPath}
onAddTerminalSelection={composer.addTerminalSelectionAttachment}
onChangeCwd={changeSessionCwd}
/>
</Pane>

View file

@ -14,13 +14,12 @@ import { SidebarPanelLabel } from '../shell/sidebar-label'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
import { $rightSidebarTab, type RightSidebarTabId, setRightSidebarTab } from './store'
import { TerminalTab } from './terminal'
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
import { TerminalSlot } from './terminal/persistent'
interface RightSidebarPaneProps {
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
onAddTerminalSelection: (text: string, label?: string) => void
onChangeCwd: (path: string) => Promise<void> | void
}
@ -38,10 +37,10 @@ const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
export function RightSidebarPane({
onActivateFile,
onActivateFolder,
onAddTerminalSelection,
onChangeCwd
}: RightSidebarPaneProps) {
const activeTab = useStore($rightSidebarTab)
const terminalTakeover = useStore($terminalTakeover)
const currentBranch = useStore($currentBranch).trim()
const currentCwd = useStore($currentCwd).trim()
const hasCwd = currentCwd.length > 0
@ -54,6 +53,7 @@ export function RightSidebarPane({
: 'No folder selected'
const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd)
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
@ -82,15 +82,19 @@ export function RightSidebarPane({
}
}
const tabs = terminalTakeover
? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal')
: RIGHT_SIDEBAR_TABS
return (
<aside
aria-label="Right sidebar"
className="before:pointer-events-none relative flex h-full w-full min-w-0 flex-col overflow-hidden border-l border-(--ui-stroke-secondary) bg-(--ui-sidebar-surface-background) pt-(--titlebar-height) text-(--ui-text-tertiary) shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)] before:absolute before:inset-x-0 before:top-(--titlebar-height) before:z-1 before:h-px before:bg-(--ui-stroke-tertiary)"
>
<RightSidebarChrome activeTab={activeTab} branch={currentBranch} />
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
{activeTab === 'terminal' ? (
<TerminalTab cwd={currentCwd} onAddSelectionToChat={onAddTerminalSelection} />
{effectiveTab === 'terminal' ? (
<TerminalSlot />
) : (
<FilesystemTab
cwd={currentCwd}
@ -113,12 +117,20 @@ export function RightSidebarPane({
)
}
function RightSidebarChrome({ activeTab, branch }: { activeTab: RightSidebarTabId; branch: string }) {
function RightSidebarChrome({
activeTab,
branch,
tabs
}: {
activeTab: RightSidebarTabId
branch: string
tabs: readonly RightSidebarTab[]
}) {
return (
<header className="shrink-0 bg-transparent text-[0.75rem]">
<div className="flex items-center gap-2 border-b border-(--ui-stroke-tertiary) px-2.5 py-1">
<nav aria-label="Right sidebar panels" className="flex min-w-0 items-center gap-1">
{RIGHT_SIDEBAR_TABS.map(tab => (
{tabs.map(tab => (
<button
aria-label={tab.label}
aria-pressed={tab.id === activeTab}

View file

@ -1,9 +1,15 @@
import { atom } from 'nanostores'
import { persistBoolean, storedBoolean } from '@/lib/storage'
export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web'
export const $rightSidebarTab = atom<RightSidebarTabId>('files')
const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover'
export function setRightSidebarTab(tab: RightSidebarTabId) {
$rightSidebarTab.set(tab)
}
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)

View file

@ -1,9 +1,13 @@
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'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store'
import { addSelectionShortcutLabel } from './selection'
import { useTerminalSession } from './use-terminal-session'
@ -18,13 +22,32 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
cwd,
onAddSelectionToChat
})
const takeover = useStore($terminalTakeover)
const label = takeover ? 'Return to split view' : 'Focus terminal view'
const toggleTakeover = () => {
// Pre-select the Terminal tab so the slot is ready to host us on return.
if (takeover) setRightSidebarTab('terminal')
setTerminalTakeover(!takeover)
}
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="flex h-7 shrink-0 items-center px-3">
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div className="flex h-7 shrink-0 items-center gap-2 px-3">
<SidebarPanelLabel>{shellName}</SidebarPanelLabel>
<Button
aria-label={label}
className="ml-auto size-6 rounded-md"
onClick={toggleTakeover}
size="icon"
title={label}
type="button"
variant="ghost"
>
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
</Button>
</div>
<div className="relative min-h-0 flex-1 px-2 pb-2">
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
{status === 'starting' && (
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
<Loader
@ -53,8 +76,12 @@ 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. */}
<div
className="h-full min-h-0 overflow-hidden px-1 py-1 text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-transparent! [&_.xterm-viewport]:bg-transparent!"
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
ref={hostRef}
/>
</div>

View file

@ -0,0 +1,110 @@
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
import { TerminalTab } from './index'
import { TERMINAL_BG } from './selection'
/**
* One xterm Terminal mounted at the layout root and CSS-overlayed onto
* whichever `<TerminalSlot />` is active. Moving the host DOM detaches xterm's
* WebGL renderer (it observes its own attachment) and resets the screen, so
* the host stays put and we chase the slot's bounding rect with position:fixed.
*/
const $slot = atom<HTMLElement | null>(null)
const SLOT_CLASS = 'relative flex min-h-0 min-w-0 flex-1 flex-col'
export function TerminalSlot({ className = SLOT_CLASS }: { className?: string }) {
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = ref.current
if (!el) return
$slot.set(el)
return () => {
if ($slot.get() === el) $slot.set(null)
}
}, [])
return <div className={className} ref={ref} />
}
interface PersistentTerminalProps {
cwd: string
onAddSelectionToChat: (text: string, label?: string) => void
}
interface Rect {
top: number
left: number
width: number
height: number
}
const sameRect = (a: Rect | null, b: Rect) =>
!!a && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height
export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerminalProps) {
const slot = useStore($slot)
const [rect, setRect] = useState<Rect | null>(null)
const [ready, setReady] = useState(false)
useLayoutEffect(() => {
if (!slot) {
setRect(null)
return
}
let prev: Rect | null = null
let frame = 0
const tick = () => {
const r = slot.getBoundingClientRect()
// floor top/left + ceil right/bottom: overlay always covers the slot's
// full pixel footprint, so half-pixel rects can't leak page bg through.
const top = Math.floor(r.top)
const left = Math.floor(r.left)
const next: Rect = { top, left, width: Math.ceil(r.right) - left, height: Math.ceil(r.bottom) - top }
if (!sameRect(prev, next)) {
prev = next
setRect(next)
if (next.width > 0 && next.height > 0) setReady(true)
}
frame = requestAnimationFrame(tick)
}
tick()
return () => cancelAnimationFrame(frame)
}, [slot])
const visible = Boolean(rect && rect.width > 0 && rect.height > 0)
const style: CSSProperties = {
position: 'fixed',
top: rect?.top ?? 0,
left: rect?.left ?? 0,
width: rect?.width ?? 0,
height: rect?.height ?? 0,
display: 'flex',
flexDirection: 'column',
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
zIndex: 4,
backgroundColor: TERMINAL_BG,
contain: 'layout size paint'
}
// Defer mount until real dims — booting xterm at 0×0 starts the shell at
// 80×24, then the first ResizeObserver SIGWINCH redraws the prompt on a
// new line. After first measurement we keep it mounted forever.
return (
<div aria-hidden={!visible} style={style}>
{ready && <TerminalTab cwd={cwd} onAddSelectionToChat={onAddSelectionToChat} />}
</div>
)
}

View file

@ -1,18 +1,19 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
// Solarized (Ethan Schoonover) for both modes. The accent palette is shared
// — Schoonover's design is that ANSI 015 are identical between Light and
// Dark; only fg/bg/cursor swap (`base00/01` vs `base0/1`). We skip both
// Solarized backgrounds (`base3` cream / `base03` slate) and keep the glass
// translucent in either mode.
//
// Heads-up: ANSI 7 (`white` = `base2` #eee8d5) is the lightest cream by
// design — near-invisible against light glass. Bump to `base1` (#93a1a1)
// if anything that emits `\x1b[37m` (e.g. tmux status bars) breaks.
//
// Palette source: altercation/solarized (iTerm2 scheme).
const SOLARIZED_ANSI: ITheme = {
// 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'
const THEME: ITheme = {
background: TERMINAL_BG,
foreground: '#839496',
cursor: '#93a1a1',
cursorAccent: TERMINAL_BG,
selectionBackground: '#586e7555',
black: '#073642',
red: '#dc322f',
green: '#859900',
@ -21,67 +22,45 @@ const SOLARIZED_ANSI: ITheme = {
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#002b36',
brightRed: '#cb4b16',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightBlack: '#586e75',
brightRed: '#f25c54',
brightGreen: '#b3d437',
brightYellow: '#f7c948',
brightBlue: '#5fb3ff',
brightMagenta: '#ff6ab4',
brightCyan: '#5cd9c8',
brightWhite: '#fdf6e3'
}
const TRANSPARENT_GLASS: ITheme = {
background: '#00000000',
selectionBackground: '#8c8c8c33'
}
// The only thing Schoonover swaps between modes: the fg + cursor pair.
const MODE_TONES = {
light: { foreground: '#657b83', cursor: '#586e75', cursorAccent: '#fdf6e3' }, // base00 / base01 / base3
dark: { foreground: '#839496', cursor: '#93a1a1', cursorAccent: '#002b36' } // base0 / base1 / base03
}
export const terminalTheme = (mode: 'light' | 'dark'): ITheme => ({
...SOLARIZED_ANSI,
...TRANSPARENT_GLASS,
...MODE_TONES[mode]
})
export const terminalTheme = (): ITheme => THEME
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export function isAddSelectionShortcut(event: KeyboardEvent) {
return isMacPlatform()
? event.metaKey && !event.shiftKey && event.key.toLowerCase() === 'l'
: event.ctrlKey && !event.shiftKey && event.key.toLowerCase() === 'l'
}
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
function selectionLineCount(text: string) {
return Math.max(1, text.trim().split(/\r?\n/).length)
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
}
export function terminalSelectionLabel(term: Terminal, shellName: string, text: string) {
const position = term.getSelectionPosition()
const pos = term.getSelectionPosition()
if (position) {
return position.start.y === position.end.y
? `${shellName}:${position.start.y}`
: `${shellName}:${position.start.y}-${position.end.y}`
if (pos) {
return pos.start.y === pos.end.y ? `${shellName}:${pos.start.y}` : `${shellName}:${pos.start.y}-${pos.end.y}`
}
const lines = selectionLineCount(text)
const lines = Math.max(1, text.trim().split(/\r?\n/).length)
return `${shellName}:${lines} line${lines === 1 ? '' : 's'}`
}
export function terminalSelectionAnchor(host: HTMLDivElement): CSSProperties | null {
const selectionRects = Array.from(host.querySelectorAll<HTMLElement>('.xterm-selection div'))
const rect = Array.from(host.querySelectorAll<HTMLElement>('.xterm-selection div'))
.map(node => node.getBoundingClientRect())
.filter(rect => rect.width > 0 && rect.height > 0)
const rect = selectionRects.at(-1)
.filter(r => r.width > 0 && r.height > 0)
.at(-1)
if (!rect) {
return null

View file

@ -1,17 +1,19 @@
import { FitAddon } from '@xterm/addon-fit'
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 type { CSSProperties } from 'react'
import { triggerHaptic } from '@/lib/haptics'
import { useTheme } from '@/themes/context'
import { isAddSelectionShortcut, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection'
type TerminalStatus = 'closed' | 'open' | 'starting'
const HERMES_PATHS_MIME = 'application/x-hermes-paths'
function readEscapeSequence(data: string, index: number) {
if (data.charCodeAt(index) !== 0x1b || index + 1 >= data.length) {
return null
@ -93,6 +95,58 @@ interface UseTerminalSessionOptions {
onAddSelectionToChat: (text: string, label?: string) => void
}
function transferHasDropCandidates(t: DataTransfer): boolean {
if (t.types?.includes(HERMES_PATHS_MIME)) return true
if ((t.files?.length ?? 0) > 0) return true
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
if (t.items[i]?.kind === 'file') return true
}
return false
}
function collectDroppedPaths(t: DataTransfer): string[] {
const seen = new Set<string>()
const push = (value: unknown) => {
if (typeof value !== 'string') return
const path = value.trim()
if (path) seen.add(path)
}
try {
const raw = t.getData(HERMES_PATHS_MIME)
if (raw) for (const entry of JSON.parse(raw) as { path?: unknown }[]) push(entry?.path)
} catch {
// Malformed in-app drag payload — fall through to OS files.
}
const getPath = window.hermesDesktop?.getPathForFile
const addFile = (file: File | null) => {
if (!file || !getPath) return
try {
push(getPath(file))
} catch {
// File handle unavailable.
}
}
for (let i = 0; i < (t.files?.length ?? 0); i += 1) addFile(t.files.item(i))
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
const item = t.items[i]
if (item?.kind === 'file') addFile(item.getAsFile())
}
return [...seen]
}
function quotePathForShell(path: string, shellName: string): string {
const shell = shellName.toLowerCase()
if (shell.includes('powershell') || shell.includes('pwsh')) return `'${path.replace(/'/g, "''")}'`
if (shell.includes('cmd')) return `"${path.replace(/"/g, '""')}"`
return `'${path.replace(/'/g, "'\\''")}'`
}
export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSessionOptions) {
const hostRef = useRef<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
@ -101,8 +155,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const selectionLabelRef = useRef('')
const selectionRef = useRef('')
const onAddSelectionToChatRef = useRef(onAddSelectionToChat)
const { resolvedMode } = useTheme()
const resolvedModeRef = useRef(resolvedMode)
const [status, setStatus] = useState<TerminalStatus>('starting')
const [selection, setSelection] = useState('')
const [selectionStyle, setSelectionStyle] = useState<CSSProperties | null>(null)
@ -112,14 +164,6 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
onAddSelectionToChatRef.current = onAddSelectionToChat
}, [onAddSelectionToChat])
useEffect(() => {
resolvedModeRef.current = resolvedMode
if (termRef.current) {
termRef.current.options.theme = terminalTheme(resolvedMode)
}
}, [resolvedMode])
const addSelectionToChat = useCallback(() => {
const selectedText = selectionRef.current || termRef.current?.getSelection() || ''
@ -186,7 +230,7 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
lineHeight: 1.12,
macOptionIsMeta: true,
scrollback: 1000,
theme: terminalTheme(resolvedModeRef.current)
theme: terminalTheme()
})
const fit = new FitAddon()
@ -197,6 +241,46 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
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) {
// eslint-disable-next-line no-console
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'copy'
}
const onDrop = (e: DragEvent) => {
const id = sessionIdRef.current
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
e.preventDefault()
e.stopPropagation()
const paths = collectDroppedPaths(e.dataTransfer)
if (!paths.length) return
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
term.focus()
triggerHaptic('selection')
}
host.addEventListener('dragenter', onDragOver)
host.addEventListener('dragover', onDragOver)
host.addEventListener('drop', onDrop)
cleanup.push(() => {
host.removeEventListener('dragenter', onDragOver)
host.removeEventListener('dragover', onDragOver)
host.removeEventListener('drop', onDrop)
})
const fitAndResize = () => {
if (disposed || !host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
@ -211,15 +295,30 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
const id = sessionIdRef.current
if (id && (lastSentSize?.cols !== term.cols || lastSentSize.rows !== term.rows)) {
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 })
}
}
const resizeObserver = new ResizeObserver(fitAndResize)
// Coalesce ResizeObserver bursts through rAF — running fit.fit()
// synchronously while sibling panes are mid-transition (e.g. file browser
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
let pendingFrame = 0
const scheduleResize = () => {
if (pendingFrame) return
pendingFrame = window.requestAnimationFrame(() => {
pendingFrame = 0
if (!disposed) fitAndResize()
})
}
const resizeObserver = new ResizeObserver(scheduleResize)
resizeObserver.observe(host)
cleanup.push(() => resizeObserver.disconnect())
cleanup.push(() => {
resizeObserver.disconnect()
if (pendingFrame) window.cancelAnimationFrame(pendingFrame)
})
const dataDisposable = term.onData(data => {
const id = sessionIdRef.current
@ -309,7 +408,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
term.write(`\r\n[terminal exited${signal ? `: ${signal}` : code !== null ? `: ${code}` : ''}]\r\n`)
})
)
window.requestAnimationFrame(fitAndResize)
window.requestAnimationFrame(() => {
fitAndResize()
term.focus()
})
})
.catch(error => {
setStatus('closed')

4
package-lock.json generated
View file

@ -88,6 +88,7 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -9001,8 +9002,7 @@
"node_modules/@xterm/addon-webgl": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==",
"license": "MIT"
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",