- {shellName}
+ {shellName}
-
+
{status === 'starting' && (
)}
- {/* 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. */}
diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
index 5b9b151f5ba..0a8df746b3f 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
@@ -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'
}
diff --git a/apps/desktop/src/app/right-sidebar/terminal/selection.ts b/apps/desktop/src/app/right-sidebar/terminal/selection.ts
index 4f0049be8e3..955a9ea1f18 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/selection.ts
+++ b/apps/desktop/src/app/right-sidebar/terminal/selection.ts
@@ -1,38 +1,101 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
-// Solarized-derived palette, but with bright ANSI 8–15 promoted to real
-// accent variants instead of Schoonover's UI grays. Hermes' TUI skins (gold,
-// crimson, ...) emit bright SGR codes that would otherwise wash out to gray.
-// We always render the dark canvas — the app's light surfaces can't host the
-// default skin without dropping below readable contrast.
-export const TERMINAL_BG = '#002b36'
+import type { DesktopTerminalPalette } from '@/themes/types'
-const THEME: ITheme = {
- background: TERMINAL_BG,
- foreground: '#839496',
- cursor: '#93a1a1',
- cursorAccent: TERMINAL_BG,
- selectionBackground: '#586e7555',
- black: '#073642',
- red: '#dc322f',
- green: '#859900',
- yellow: '#b58900',
- blue: '#268bd2',
- magenta: '#d33682',
- cyan: '#2aa198',
- white: '#eee8d5',
- brightBlack: '#586e75',
- brightRed: '#f25c54',
- brightGreen: '#b3d437',
- brightYellow: '#f7c948',
- brightBlue: '#5fb3ff',
- brightMagenta: '#ff6ab4',
- brightCyan: '#5cd9c8',
- brightWhite: '#fdf6e3'
+// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
+// fixed table per theme type, not luminance-derived. Light/dark diverge on
+// purpose so each stays legible (e.g. mustard yellow on white).
+const DARK_THEME: ITheme = {
+ background: '#1e1e1e',
+ foreground: '#cccccc',
+ cursor: '#cccccc',
+ cursorAccent: '#1e1e1e',
+ selectionBackground: '#264f7866',
+ black: '#000000',
+ red: '#cd3131',
+ green: '#0dbc79',
+ yellow: '#e5e510',
+ blue: '#2472c8',
+ magenta: '#bc3fbc',
+ cyan: '#11a8cd',
+ white: '#e5e5e5',
+ brightBlack: '#666666',
+ brightRed: '#f14c4c',
+ brightGreen: '#23d18b',
+ brightYellow: '#f5f543',
+ brightBlue: '#3b8eea',
+ brightMagenta: '#d670d6',
+ brightCyan: '#29b8db',
+ brightWhite: '#e5e5e5'
}
-export const terminalTheme = (): ITheme => THEME
+const LIGHT_THEME: ITheme = {
+ background: '#ffffff',
+ foreground: '#333333',
+ cursor: '#333333',
+ cursorAccent: '#ffffff',
+ selectionBackground: '#add6ff80',
+ black: '#000000',
+ red: '#cd3131',
+ green: '#00bc00',
+ yellow: '#949800',
+ blue: '#0451a5',
+ magenta: '#bc05bc',
+ cyan: '#0598bc',
+ white: '#555555',
+ brightBlack: '#666666',
+ brightRed: '#cd3131',
+ brightGreen: '#14ce14',
+ brightYellow: '#b5ba00',
+ brightBlue: '#0451a5',
+ brightMagenta: '#bc05bc',
+ brightCyan: '#0598bc',
+ brightWhite: '#a5a5a5'
+}
+
+// Palette by painted mode, optionally overlaid with an imported theme's ANSI
+// palette (Solarized terminal for the Solarized skin, etc.). `palette` only
+// fills the slots it defines, so a partial import keeps the mode defaults for
+// the rest. `background` is a fallback only — withSurface swaps in the live skin
+// surface at runtime (keeping transparency); minimumContrastRatio keeps colors
+// crisp against it.
+export function terminalTheme(mode: 'light' | 'dark', palette?: DesktopTerminalPalette): ITheme {
+ const base = mode === 'dark' ? DARK_THEME : LIGHT_THEME
+
+ if (!palette) {
+ return base
+ }
+
+ const overlay = { ...base } as Record
+
+ 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')
diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts
index 7442c64ee86..1e0b5f93134 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts
+++ b/apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts
@@ -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) {
+ 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(null)
const termRef = useRef(null)
+ const webglRef = useRef(null)
const sessionIdRef = useRef(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,
diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts
index 382a2cd7f37..703941c9367 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts
@@ -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)
diff --git a/apps/desktop/src/app/shell/app-shell.tsx b/apps/desktop/src/app/shell/app-shell.tsx
index c4d2e368eaf..8e548734496 100644
--- a/apps/desktop/src/app/shell/app-shell.tsx
+++ b/apps/desktop/src/app/shell/app-shell.tsx
@@ -29,9 +29,19 @@ interface AppShellProps {
children: ReactNode
leftStatusbarItems?: readonly StatusbarItem[]
leftTitlebarTools?: readonly TitlebarTool[]
+ // Fixed-position overlays that must share '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 , 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}
+ {/* 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}
+
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index c471d0f517a..53ce2dcc150 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -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: ,
+ 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,
diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx
index 8651ecd3ee9..61e7e6969ad 100644
--- a/apps/desktop/src/components/pane-shell/pane-shell.tsx
+++ b/apps/desktop/src/components/pane-shell/pane-shell.tsx
@@ -30,6 +30,8 @@ export interface PaneProps {
children?: ReactNode
className?: string
defaultOpen?: boolean
+ /** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */
+ divider?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
@@ -94,19 +96,35 @@ const remPx = () =>
? 16
: Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16
-// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem") to pixels for drag clamping.
+const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth)
+
+// Resolves PaneProps.minWidth/maxWidth (number | "Npx" | "Nrem" | "Nvw" | "N%") to
+// pixels for drag clamping. Viewport units resolve against the current window width.
function widthToPx(value: WidthValue | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined
}
- const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem)?$/)
+ const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|%)?$/)
if (!match) {
return undefined
}
- return Number.parseFloat(match[1]) * (match[2] === 'rem' ? remPx() : 1)
+ const n = Number.parseFloat(match[1])
+
+ switch (match[2]) {
+ case 'rem':
+ return n * remPx()
+
+ case 'vw':
+
+ case '%':
+ return (n * viewportPx()) / 100
+
+ default:
+ return n
+ }
}
function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement {
@@ -217,6 +235,7 @@ export function Pane({
children,
className,
defaultOpen = true,
+ divider = false,
disabled = false,
hoverReveal = false,
id,
@@ -409,6 +428,7 @@ export function Pane({
role="separator"
tabIndex={0}
>
+ {divider && }
)}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 9c6bf1984d4..3dba6b846c4 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -1151,7 +1151,7 @@ export const en: Translations = {
],
startVoice: 'Start voice conversation',
queueMessage: 'Queue message',
- steer: 'Steer the current run (⌘⏎)',
+ steer: 'Steer the current run',
stop: 'Stop',
send: 'Send',
speaking: 'Speaking',
@@ -1492,6 +1492,8 @@ export const en: Translations = {
branch: branch => `branch ${branch}`,
closeCommandCenter: 'Close Command Center',
openCommandCenter: 'Open Command Center',
+ showTerminal: 'Show terminal',
+ hideTerminal: 'Hide terminal',
gateway: 'Gateway',
gatewayReady: 'ready',
gatewayNeedsSetup: 'needs setup',
@@ -1547,8 +1549,7 @@ export const en: Translations = {
tryAgain: 'Try again',
loadingTree: 'Loading file tree',
loadingFiles: 'Loading files',
- terminalFocus: 'Focus terminal view',
- terminalSplit: 'Return to split view',
+ terminalHide: 'Hide terminal',
addToChat: 'Add to chat'
},
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index bcac1c8950b..956788067ed 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -1625,6 +1625,8 @@ export const ja = defineLocale({
branch: branch => `ブランチ ${branch}`,
closeCommandCenter: 'コマンドセンターを閉じる',
openCommandCenter: 'コマンドセンターを開く',
+ showTerminal: 'ターミナルを表示',
+ hideTerminal: 'ターミナルを非表示',
gateway: 'ゲートウェイ',
gatewayReady: '準備完了',
gatewayNeedsSetup: '設定が必要',
@@ -1680,8 +1682,7 @@ export const ja = defineLocale({
tryAgain: '再試行',
loadingTree: 'ファイルツリーを読み込み中',
loadingFiles: 'ファイルを読み込み中',
- terminalFocus: 'ターミナルビューにフォーカス',
- terminalSplit: '分割ビューに戻る',
+ terminalHide: 'ターミナルを非表示',
addToChat: 'チャットに追加'
},
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index da5d5a28607..77424e426ac 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -1154,6 +1154,8 @@ export interface Translations {
branch: (branch: string) => string
closeCommandCenter: string
openCommandCenter: string
+ showTerminal: string
+ hideTerminal: string
gateway: string
gatewayReady: string
gatewayNeedsSetup: string
@@ -1209,8 +1211,7 @@ export interface Translations {
tryAgain: string
loadingTree: string
loadingFiles: string
- terminalFocus: string
- terminalSplit: string
+ terminalHide: string
addToChat: string
}
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 15e39235db7..9f045c4d022 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -1586,6 +1586,8 @@ export const zhHant = defineLocale({
branch: branch => `分支 ${branch}`,
closeCommandCenter: '關閉命令中心',
openCommandCenter: '開啟命令中心',
+ showTerminal: '顯示終端機',
+ hideTerminal: '隱藏終端機',
gateway: '閘道',
gatewayReady: '就緒',
gatewayNeedsSetup: '需要設定',
@@ -1641,8 +1643,7 @@ export const zhHant = defineLocale({
tryAgain: '重試',
loadingTree: '正在載入檔案樹',
loadingFiles: '正在載入檔案',
- terminalFocus: '聚焦終端機檢視',
- terminalSplit: '返回分割檢視',
+ terminalHide: '隱藏終端機',
addToChat: '新增至聊天'
},
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 6990c4ab6a9..f6b119a2777 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -1337,7 +1337,7 @@ export const zh: Translations = {
],
startVoice: '开始语音对话',
queueMessage: '排队消息',
- steer: '引导当前运行 (⌘⏎)',
+ steer: '引导当前运行',
stop: '停止',
send: '发送',
speaking: '讲话中',
@@ -1672,6 +1672,8 @@ export const zh: Translations = {
branch: branch => `分支 ${branch}`,
closeCommandCenter: '关闭命令中心',
openCommandCenter: '打开命令中心',
+ showTerminal: '显示终端',
+ hideTerminal: '隐藏终端',
gateway: '网关',
gatewayReady: '就绪',
gatewayNeedsSetup: '需要设置',
@@ -1727,8 +1729,7 @@ export const zh: Translations = {
tryAgain: '重试',
loadingTree: '正在加载文件树',
loadingFiles: '正在加载文件',
- terminalFocus: '聚焦终端视图',
- terminalSplit: '返回分栏视图',
+ terminalHide: '隐藏终端',
addToChat: '添加到对话'
},
diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts
index c6c9cee48d8..5e3a725f303 100644
--- a/apps/desktop/src/lib/chat-messages.ts
+++ b/apps/desktop/src/lib/chat-messages.ts
@@ -61,6 +61,9 @@ export type GatewayEventPayload = {
// secret.request (skill credential capture)
env_var?: string
prompt?: string
+ // terminal.read.request (GUI agent reading the in-app terminal pane)
+ start?: number
+ count?: number
}
export function textPart(text: string): ChatMessagePart {
diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts
index 0efb77965f3..7c4a83f61aa 100644
--- a/apps/desktop/src/lib/keybinds/actions.ts
+++ b/apps/desktop/src/lib/keybinds/actions.ts
@@ -13,13 +13,7 @@ export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel'
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
// layout, appearance, and the panel-opener.
-export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = [
- 'composer',
- 'profiles',
- 'session',
- 'navigation',
- 'view'
-]
+export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = ['composer', 'profiles', 'session', 'navigation', 'view']
export interface KeybindActionMeta {
id: string
@@ -43,6 +37,11 @@ const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE
defaults: [comboForSlot(i + 1)]
}))
+// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
+// `mod` keeps one binding cross-platform; on macOS this shadows the system
+// window-cycler, which is fine for a single-window app.
+const TERMINAL_TOGGLE_DEFAULTS = ['mod+`', 'mod+shift+`']
+
// Positional jumps — ^1…^9, mirroring profiles' ⌘1…⌘9.
export const SESSION_SLOT_COUNT = 9
@@ -90,7 +89,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
{ id: 'view.showFiles', category: 'view', defaults: [] },
- { id: 'view.showTerminal', category: 'view', defaults: [] },
+ { id: 'view.showTerminal', category: 'view', defaults: TERMINAL_TOGGLE_DEFAULTS },
// ⌘\ — the backslash reads like a mirror line flipping the layout.
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
diff --git a/apps/desktop/src/lib/keybinds/combo.ts b/apps/desktop/src/lib/keybinds/combo.ts
index 3e676ec3e31..b203ded952d 100644
--- a/apps/desktop/src/lib/keybinds/combo.ts
+++ b/apps/desktop/src/lib/keybinds/combo.ts
@@ -10,8 +10,7 @@
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`.
-export const IS_MAC =
- typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
+export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
@@ -140,33 +139,38 @@ function labelForBase(base: string): string {
return base.length === 1 ? base.toUpperCase() : base
}
-// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
-export function formatCombo(combo: string): string {
+function labelForMod(mod: string): string {
+ if (mod === 'mod') {
+ return IS_MAC ? '⌘' : 'Ctrl'
+ }
+
+ if (mod === 'ctrl') {
+ return IS_MAC ? '⌃' : 'Ctrl'
+ }
+
+ if (mod === 'alt') {
+ return IS_MAC ? '⌥' : 'Alt'
+ }
+
+ if (mod === 'shift') {
+ return IS_MAC ? '⇧' : 'Shift'
+ }
+
+ return mod
+}
+
+// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
+// one cap per token for
.
+export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
- const mods = parts
- const modLabels = mods.map(mod => {
- if (mod === 'mod') {
- return IS_MAC ? '⌘' : 'Ctrl'
- }
+ return [...parts.map(labelForMod), labelForBase(base)]
+}
- if (mod === 'ctrl') {
- return IS_MAC ? '⌃' : 'Ctrl'
- }
-
- if (mod === 'alt') {
- return IS_MAC ? '⌥' : 'Alt'
- }
-
- if (mod === 'shift') {
- return IS_MAC ? '⇧' : 'Shift'
- }
-
- return mod
- })
-
- const tokens = [...modLabels, labelForBase(base)]
+// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
+export function formatCombo(combo: string): string {
+ const tokens = comboTokens(combo)
return IS_MAC ? tokens.join('') : tokens.join('+')
}
@@ -178,9 +182,9 @@ export function isEditableTarget(target: EventTarget | null): boolean {
return Boolean(
el?.isContentEditable ||
- el instanceof HTMLInputElement ||
- el instanceof HTMLTextAreaElement ||
- el instanceof HTMLSelectElement
+ el instanceof HTMLInputElement ||
+ el instanceof HTMLTextAreaElement ||
+ el instanceof HTMLSelectElement
)
}
diff --git a/apps/desktop/src/themes/context.tsx b/apps/desktop/src/themes/context.tsx
index 4a3275b7dc1..f7bc07c3b7e 100644
--- a/apps/desktop/src/themes/context.tsx
+++ b/apps/desktop/src/themes/context.tsx
@@ -252,7 +252,15 @@ interface ThemeContextValue {
theme: DesktopTheme
themeName: string
mode: ThemeMode
+ /** The light/dark switch the user picked. */
resolvedMode: 'light' | 'dark'
+ /**
+ * The mode actually painted, derived from the active background's luminance.
+ * Differs from `resolvedMode` for skins that keep a bright surface in "dark"
+ * (or vice-versa). Surface-bound UI (e.g. the terminal palette) should key off
+ * this so it matches what's on screen instead of inverting.
+ */
+ renderedMode: 'light' | 'dark'
availableThemes: Array<{ name: string; label: string; description: string }>
setTheme: (name: string) => void
setMode: (mode: ThemeMode) => void
@@ -265,6 +273,7 @@ const ThemeContext = createContext({
themeName: DEFAULT_SKIN_NAME,
mode: 'light',
resolvedMode: 'light',
+ renderedMode: 'light',
availableThemes: SKIN_LIST,
setTheme: () => {},
setMode: () => {}
@@ -310,6 +319,12 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const resolvedMode = resolveMode(mode, systemDark)
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
+ // What actually gets painted (matches the `.dark` class applyTheme toggles).
+ const renderedMode = useMemo(
+ () => renderedModeFor(activeTheme.colors, resolvedMode),
+ [activeTheme, resolvedMode]
+ )
+
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
// Assign to whichever profile is live right now (read fresh so the callbacks
@@ -331,8 +346,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// (`appearance.toggleMode`) so it shows up in the hotkey map and is rebindable.
const value = useMemo(
- () => ({ theme: activeTheme, themeName, mode, resolvedMode, availableThemes, setTheme, setMode }),
- [activeTheme, themeName, mode, resolvedMode, availableThemes, setTheme, setMode]
+ () => ({ theme: activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode }),
+ [activeTheme, themeName, mode, resolvedMode, renderedMode, availableThemes, setTheme, setMode]
)
return {children}
diff --git a/apps/desktop/src/themes/install.ts b/apps/desktop/src/themes/install.ts
index 497243a65f7..792552f9af7 100644
--- a/apps/desktop/src/themes/install.ts
+++ b/apps/desktop/src/themes/install.ts
@@ -48,19 +48,27 @@ export function buildThemeFromMarketplace(result: DesktopMarketplaceThemeResult)
const label = file.label || raw.name || result.displayName
const { mode, theme } = convertVscodeColorTheme(raw, { label, source: result.extensionId })
- return { mode, palette: theme.colors }
+ return { mode, palette: theme.colors, terminal: theme.terminal }
})
- const fallback = variants[0].palette
- const light = variants.find(variant => variant.mode === 'light')?.palette
- const dark = variants.find(variant => variant.mode === 'dark')?.palette
+ const fallback = variants[0]
+ const light = variants.find(variant => variant.mode === 'light') ?? fallback
+ const dark = variants.find(variant => variant.mode === 'dark') ?? fallback
+
+ // The terminal ANSI palette tracks the painted variant the same way colors do
+ // (light → terminal, dark → darkTerminal); each falls back to the other so a
+ // single-variant import still themes the terminal in both modes.
+ const terminal = light.terminal ?? dark.terminal
+ const darkTerminal = dark.terminal ?? light.terminal
return {
name: vscodeThemeSlug(result.displayName),
label: result.displayName,
description: `VS Code · ${result.extensionId}`,
- colors: light ?? dark ?? fallback,
- darkColors: dark ?? light ?? fallback
+ colors: light.palette,
+ darkColors: dark.palette,
+ ...(terminal ? { terminal } : {}),
+ ...(darkTerminal ? { darkTerminal } : {})
}
}
diff --git a/apps/desktop/src/themes/types.ts b/apps/desktop/src/themes/types.ts
index 09bff38ca59..3aefda3eaa3 100644
--- a/apps/desktop/src/themes/types.ts
+++ b/apps/desktop/src/themes/types.ts
@@ -54,6 +54,37 @@ export interface DesktopThemeTypography {
fontUrl?: string
}
+/**
+ * Integrated-terminal ANSI palette (xterm `ITheme`, minus `background`).
+ *
+ * Populated only when a converted VS Code theme ships a full `terminal.ansi*`
+ * set; otherwise the terminal keeps its built-in VS Code default palette.
+ * `background` is intentionally absent — the pane always paints the live skin
+ * surface so it stays translucent.
+ */
+export interface DesktopTerminalPalette {
+ foreground?: string
+ cursor?: string
+ /** Keeps its source alpha — xterm blends it over the surface. */
+ selectionBackground?: string
+ black?: string
+ red?: string
+ green?: string
+ yellow?: string
+ blue?: string
+ magenta?: string
+ cyan?: string
+ white?: string
+ brightBlack?: string
+ brightRed?: string
+ brightGreen?: string
+ brightYellow?: string
+ brightBlue?: string
+ brightMagenta?: string
+ brightCyan?: string
+ brightWhite?: string
+}
+
export interface DesktopTheme {
name: string
label: string
@@ -63,4 +94,8 @@ export interface DesktopTheme {
/** Hand-tuned dark palette. Skins like `nous` ship one. */
darkColors?: DesktopThemeColors
typography?: Partial
+ /** Light-variant terminal ANSI palette (also the fallback for dark). */
+ terminal?: DesktopTerminalPalette
+ /** Dark-variant terminal ANSI palette. Falls back to `terminal`. */
+ darkTerminal?: DesktopTerminalPalette
}
diff --git a/apps/desktop/src/themes/vscode.ts b/apps/desktop/src/themes/vscode.ts
index 491f58d053b..67c36983a0e 100644
--- a/apps/desktop/src/themes/vscode.ts
+++ b/apps/desktop/src/themes/vscode.ts
@@ -16,7 +16,7 @@
*/
import { ensureContrast, luminance, mix, normalizeHex, readableOn } from './color'
-import type { DesktopTheme, DesktopThemeColors } from './types'
+import type { DesktopTerminalPalette, DesktopTheme, DesktopThemeColors } from './types'
// Section headers / sidebar labels render in --theme-primary directly on the
// sidebar surface as small (~10px) uppercase text, so the accent has to clear
@@ -101,6 +101,85 @@ const isDarkType = (raw: VscodeColorTheme, background: string): boolean => {
return luminance(background) < 0.4
}
+// xterm ITheme ANSI slots ← VS Code `terminal.ansi*` tokens. Background is
+// deliberately excluded — the pane keeps the live skin surface (transparency).
+const ANSI_TOKENS: ReadonlyArray = [
+ ['black', 'terminal.ansiBlack'],
+ ['red', 'terminal.ansiRed'],
+ ['green', 'terminal.ansiGreen'],
+ ['yellow', 'terminal.ansiYellow'],
+ ['blue', 'terminal.ansiBlue'],
+ ['magenta', 'terminal.ansiMagenta'],
+ ['cyan', 'terminal.ansiCyan'],
+ ['white', 'terminal.ansiWhite'],
+ ['brightBlack', 'terminal.ansiBrightBlack'],
+ ['brightRed', 'terminal.ansiBrightRed'],
+ ['brightGreen', 'terminal.ansiBrightGreen'],
+ ['brightYellow', 'terminal.ansiBrightYellow'],
+ ['brightBlue', 'terminal.ansiBrightBlue'],
+ ['brightMagenta', 'terminal.ansiBrightMagenta'],
+ ['brightCyan', 'terminal.ansiBrightCyan'],
+ ['brightWhite', 'terminal.ansiBrightWhite']
+]
+
+const BASE_ANSI: ReadonlyArray = [
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'magenta',
+ 'cyan',
+ 'white'
+]
+
+const HEX_RE = /^#[0-9a-f]{3,8}$/i
+
+/**
+ * Lift a theme's integrated-terminal ANSI palette, if it ships one.
+ *
+ * All-or-nothing on the base-8 colors: a half-filled palette mixed with our
+ * defaults reads worse than just keeping the defaults, so we adopt the theme's
+ * palette only when the full base set is present. ANSI slots flatten alpha over
+ * the editor background; selection keeps its alpha so xterm can blend it.
+ */
+function extractTerminalPalette(colors: Record, background: string): DesktopTerminalPalette | undefined {
+ const hex = (key: string): string | undefined =>
+ normalizeHex(typeof colors[key] === 'string' ? (colors[key] as string) : null, background) ?? undefined
+
+ const palette: DesktopTerminalPalette = {}
+
+ for (const [slot, token] of ANSI_TOKENS) {
+ const value = hex(token)
+
+ if (value) {
+ palette[slot] = value
+ }
+ }
+
+ if (!BASE_ANSI.every(slot => palette[slot])) {
+ return undefined
+ }
+
+ const foreground = hex('terminal.foreground')
+ const cursor = hex('terminalCursor.foreground') ?? hex('terminalCursor.background')
+ const selection = typeof colors['terminal.selectionBackground'] === 'string' ? colors['terminal.selectionBackground'].trim() : ''
+
+ if (foreground) {
+ palette.foreground = foreground
+ }
+
+ if (cursor) {
+ palette.cursor = cursor
+ }
+
+ if (HEX_RE.test(selection)) {
+ palette.selectionBackground = selection
+ }
+
+ return palette
+}
+
/** First normalizable hex among `keys`, composited over `backdrop`. */
const pick = (
colors: Record,
@@ -242,6 +321,7 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
const label = (opts.label ?? raw.name ?? 'VS Code Theme').trim()
const slug = opts.slug ?? vscodeThemeSlug(label)
+ const terminal = extractTerminalPalette(colors, background)
return {
derived,
@@ -254,7 +334,10 @@ export function convertVscodeColorTheme(raw: VscodeColorTheme, opts: ConvertOpti
// that have both a light and dark variant (a Marketplace extension family)
// recombine them into proper colors/darkColors via buildThemeFromMarketplace.
colors: palette,
- darkColors: palette
+ darkColors: palette,
+ // Only set when the theme ships a full ANSI palette — the terminal keeps
+ // its built-in VS Code defaults otherwise.
+ ...(terminal ? { terminal } : {})
}
}
}
diff --git a/run_agent.py b/run_agent.py
index 9c720bcbfe0..c717c66c178 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -376,6 +376,7 @@ class AIAgent:
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
+ read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
@@ -449,6 +450,7 @@ class AIAgent:
thinking_callback=thinking_callback,
reasoning_callback=reasoning_callback,
clarify_callback=clarify_callback,
+ read_terminal_callback=read_terminal_callback,
step_callback=step_callback,
stream_delta_callback=stream_delta_callback,
interim_assistant_callback=interim_assistant_callback,
diff --git a/tools/read_terminal_tool.py b/tools/read_terminal_tool.py
new file mode 100644
index 00000000000..c48e12a4188
--- /dev/null
+++ b/tools/read_terminal_tool.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Read the in-app terminal pane in the Hermes desktop GUI.
+
+The embedded terminal's buffer lives in the desktop renderer (xterm.js), so this
+tool round-trips through the gateway's blocking-prompt bridge — the same one
+`clarify` uses: tui_gateway emits ``terminal.read.request``, the renderer answers
+with ``terminal.read.respond``. This module is just schema + a thin dispatcher
+over the platform-injected callback.
+"""
+
+import json
+import os
+from typing import Callable, Optional
+
+from tools.registry import registry, tool_error
+
+
+def read_terminal_tool(
+ start_line: Optional[int] = None,
+ count: Optional[int] = None,
+ callback: Optional[Callable] = None,
+) -> str:
+ """Return the in-app terminal's contents (+ line metadata) as a JSON string."""
+ if callback is None:
+ return tool_error("read_terminal is only available in the Hermes desktop app.")
+
+ try:
+ window = {
+ key: max(floor, int(val))
+ for key, val, floor in (("start", start_line, 0), ("count", count, 1))
+ if val is not None
+ }
+ except (TypeError, ValueError):
+ return tool_error("start_line and count must be integers.")
+
+ try:
+ raw = callback(**window)
+ except Exception as exc:
+ return tool_error(f"Failed to read terminal: {exc}")
+
+ if not raw:
+ return tool_error("No in-app terminal is open, or the read timed out.")
+
+ # Desktop answers with a JSON object; pass it through, else wrap the raw text.
+ try:
+ return json.dumps(json.loads(raw), ensure_ascii=False)
+ except (TypeError, ValueError):
+ return json.dumps({"text": str(raw)}, ensure_ascii=False)
+
+
+def check_read_terminal_requirements() -> bool:
+ """Desktop GUI only — HERMES_DESKTOP is set on the gateway the app spawns."""
+ return (os.getenv("HERMES_DESKTOP") or "").strip().lower() in ("1", "true", "yes")
+
+
+READ_TERMINAL_SCHEMA = {
+ "name": "read_terminal",
+ "description": (
+ "Read what's currently shown in the in-app terminal pane of the Hermes "
+ "desktop GUI (the embedded shell beside this chat). Call with no arguments "
+ "to get the visible screen plus the total line count (`total_lines`). To "
+ "page through scrollback, pass `start_line` (0 = oldest line) and `count`; "
+ "valid lines are [0, total_lines). Returns JSON: "
+ "{total_lines, start, end, viewport_rows, cursor_row, text}."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "start_line": {
+ "type": "integer",
+ "description": "0-indexed first line (0 = oldest). Omit for the visible screen.",
+ },
+ "count": {
+ "type": "integer",
+ "description": "Lines to read from start_line. Defaults to the visible row count.",
+ },
+ },
+ },
+}
+
+
+registry.register(
+ name="read_terminal",
+ toolset="terminal",
+ schema=READ_TERMINAL_SCHEMA,
+ handler=lambda args, **kw: read_terminal_tool(
+ start_line=args.get("start_line"),
+ count=args.get("count"),
+ callback=kw.get("callback"),
+ ),
+ check_fn=check_read_terminal_requirements,
+ emoji="🖥️",
+)
diff --git a/toolsets.py b/toolsets.py
index 10c5dbb0ca0..901b072f46c 100644
--- a/toolsets.py
+++ b/toolsets.py
@@ -33,6 +33,9 @@ _HERMES_CORE_TOOLS = [
"web_search", "web_extract",
# Terminal + process management
"terminal", "process",
+ # Read the desktop GUI's embedded terminal pane (gated on HERMES_DESKTOP
+ # via check_fn in tools/read_terminal_tool.py — hidden outside the GUI).
+ "read_terminal",
# File manipulation
"read_file", "write_file", "patch", "search_files",
# Vision + image generation
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 69c662d6409..12bfd502fdb 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -2468,6 +2468,14 @@ def _agent_cbs(sid: str) -> dict:
"clarify_callback": lambda q, c: _block(
"clarify.request", sid, {"question": q, "choices": c}
),
+ # read_terminal tool (desktop GUI): same blocking bridge as clarify — the
+ # renderer answers terminal.read.respond with the serialized buffer.
+ "read_terminal_callback": lambda start=None, count=None: _block(
+ "terminal.read.request",
+ sid,
+ {k: v for k, v in (("start", start), ("count", count)) if v is not None},
+ timeout=30,
+ ),
}
@@ -6114,6 +6122,12 @@ def _(rid, params: dict) -> dict:
return _respond(rid, params, "answer")
+@method("terminal.read.respond")
+def _(rid, params: dict) -> dict:
+ # `text` is a JSON string of the serialized terminal buffer + line metadata.
+ return _respond(rid, params, "text")
+
+
@method("sudo.respond")
def _(rid, params: dict) -> dict:
return _respond(rid, params, "password")