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, useMemo, useRef, useState } from 'react' import type { CSSProperties } from 'react' import { triggerHaptic } from '@/lib/haptics' import { $filePreviewTarget, $previewTarget } from '@/store/preview' import { useTheme } from '@/themes/context' import { $terminalInjection } from '../store' import { makeTerminalReader, setActiveTerminalReader } from './buffer' import { isAddSelectionShortcut, resolveSurfaceColor, terminalSelectionAnchor, terminalSelectionLabel, terminalTheme } from './selection' type TerminalStatus = 'closed' | 'open' | 'starting' // ⌘/Ctrl+L is a global shortcut, so a text selection in the file preview pane // lands in this handler with no xterm selection. Label those with the previewed // file's name instead of the shell, so the composer ref reads as a file quote // rather than a bogus "zsh:N lines". function previewSelectionLabel(): string { const target = $filePreviewTarget.get() ?? $previewTarget.get() const source = target?.path || target?.url || '' return source.split(/[\\/]/).filter(Boolean).pop() || target?.label?.trim() || '' } 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 } const kind = data[index + 1] if (kind === '[') { for (let i = index + 2; i < data.length; i += 1) { const code = data.charCodeAt(i) if (code >= 0x40 && code <= 0x7e) { return data.slice(index, i + 1) } } } if (kind === ']') { for (let i = index + 2; i < data.length; i += 1) { if (data.charCodeAt(i) === 0x07) { return data.slice(index, i + 1) } if (data.charCodeAt(i) === 0x1b && data[i + 1] === '\\') { return data.slice(index, i + 2) } } } return data.slice(index, Math.min(index + 2, data.length)) } function stripEscapeSequences(data: string) { let index = 0 let text = '' while (index < data.length) { const sequence = readEscapeSequence(data, index) if (sequence) { index += sequence.length } else { text += data[index] index += 1 } } return text } // 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 = '' 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) { let index = 0 let prefix = '' while (index < data.length) { const sequence = readEscapeSequence(data, index) if (sequence) { prefix += sequence index += sequence.length } else if (data[index] === '\r' || data[index] === '\n') { index += 1 } else { return prefix + data.slice(index) } } return prefix } interface UseTerminalSessionOptions { cwd: string 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) { 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 } 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() 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) { // 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(null) const termRef = useRef(null) const webglRef = useRef(null) const sessionIdRef = useRef(null) const shellNameRef = useRef('shell') const selectionLabelRef = useRef('') const selectionRef = useRef('') const onAddSelectionToChatRef = useRef(onAddSelectionToChat) const [status, setStatus] = useState('starting') const [selection, setSelection] = useState('') const [selectionStyle, setSelectionStyle] = useState(null) const [shellName, setShellName] = useState('shell') useEffect(() => { 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 termSelection = (termRef.current?.getSelection() || selectionRef.current).trim() const selectedText = termSelection || window.getSelection()?.toString() || '' const trimmed = selectedText.trim() if (!trimmed) { return } // Terminal selection → shell-anchored label; anything else came from the // preview pane sharing this global shortcut → label it with the file. const label = termSelection ? selectionLabelRef.current || (termRef.current ? terminalSelectionLabel(termRef.current, shellNameRef.current, selectedText) : 'selection') : previewSelectionLabel() || 'selection' onAddSelectionToChatRef.current(trimmed, label) termRef.current?.clearSelection() selectionRef.current = '' selectionLabelRef.current = '' setSelection('') setSelectionStyle(null) triggerHaptic('selection') }, []) // 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(() => { const onKeyDown = (event: KeyboardEvent) => { if (!isAddSelectionShortcut(event) || !readSelection().trim()) { return } event.preventDefault() event.stopPropagation() addSelectionToChat() } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) }, [addSelectionToChat, readSelection]) useEffect(() => { const host = hostRef.current const terminalApi = window.hermesDesktop?.terminal if (!host || !terminalApi) { setStatus('closed') return } let disposed = false const cleanup: Array<() => void> = [] let lastSentSize: { cols: number; rows: number } | null = null const term = new Terminal({ allowProposedApi: true, // Opaque canvas = WebGL's crisp fast-path. allowTransparency instead bakes // glyphs as grayscale-alpha for compositing over a see-through canvas, which // reads soft on every platform; VS Code keeps it off and our surface // (--ui-bg-chrome) is opaque anyway, so withSurface paints it solid. allowTransparency: false, convertEol: true, cursorBlink: true, fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace", fontSize: 11, // VS Code's terminal renders 'normal'/'bold' (400/700); we were using Medium // (500) as the base, which reads a touch heavy at this size. fontWeight: 'normal', fontWeightBold: 'bold', letterSpacing: 0, 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: withSurface(initialThemeRef.current) }) const fit = new FitAddon() termRef.current = term term.loadAddon(fit) term.loadAddon(new Unicode11Addon()) term.loadAddon(new WebLinksAddon()) term.unicode.activeVersion = '11' // 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)) { 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) }) // 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 } try { fit.fit() } catch { return } const id = sessionIdRef.current 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() } } // 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() if (pendingFrame) { window.cancelAnimationFrame(pendingFrame) } }) const dataDisposable = term.onData(data => { 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) } }) cleanup.push(() => dataDisposable.dispose()) const selectionDisposable = term.onSelectionChange(() => { const next = term.getSelection() selectionRef.current = next selectionLabelRef.current = next.trim() ? terminalSelectionLabel(term, shellNameRef.current, next) : '' setSelection(next) setSelectionStyle(next.trim() ? terminalSelectionAnchor(host) : null) }) cleanup.push(() => selectionDisposable.dispose()) const startSession = () => void terminalApi .start({ cols: term.cols, cwd, rows: term.rows }) .then(session => { if (disposed) { void terminalApi.dispose(session.id) return } sessionIdRef.current = session.id lastSentSize = { cols: term.cols, rows: term.rows } shellNameRef.current = session.shell || 'shell' setShellName(session.shell || 'shell') const initial = term.hasSelection() ? term.getSelection() : '' selectionRef.current = initial selectionLabelRef.current = initial ? terminalSelectionLabel(term, shellNameRef.current, initial) : '' setStatus('open') 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`) }) ) window.requestAnimationFrame(() => { fitAndResize() term.clearSelection() // drop any selection painted over transient boot rows term.focus() }) }) .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() } // fonts.ready settles only already-requested faces; the regular (400), // bold (700) and italic aren't asked for until styled output paints (past // atlas init), so warm them up front — otherwise the WebGL atlas bakes a // fallback face and the terminal renders thin until a repaint. const warm = document.fonts?.load ? Promise.allSettled(['400', '700', 'italic 400'].map(v => document.fonts.load(`${v} 11px 'JetBrains Mono'`))) : Promise.resolve() void warm.then(mount, mount) return () => { disposed = true cleanup.forEach(run => run()) setActiveTerminalReader(null) const id = sessionIdRef.current sessionIdRef.current = null if (id) { void terminalApi.dispose(id) } 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]) // Flush a queued command (e.g. a provider-disconnect) into the live session. // Only active while open; the subscribe fires immediately, so a command set // before this pane mounted runs as soon as the session is ready. Clearing the // atom after writing stops a later remount from replaying a stale command. useEffect(() => { if (status !== 'open') { return } return $terminalInjection.subscribe(command => { const id = sessionIdRef.current if (!command || !id) { return } void window.hermesDesktop?.terminal?.write(id, `${command}\r`) $terminalInjection.set(null) termRef.current?.focus() }) }, [status]) return { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } }