diff --git a/agent/agent_init.py b/agent/agent_init.py index 30bb6d83705..96bfe3d873f 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -187,6 +187,7 @@ def init_agent( 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, @@ -417,6 +418,7 @@ def init_agent( agent.thinking_callback = thinking_callback agent.reasoning_callback = reasoning_callback agent.clarify_callback = clarify_callback + agent.read_terminal_callback = read_terminal_callback agent.step_callback = step_callback agent.stream_delta_callback = stream_delta_callback agent.interim_assistant_callback = interim_assistant_callback diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index f9bfb7a4319..daffc025d9b 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -49,7 +49,7 @@ def _ra(): AGENT_RUNTIME_POST_HOOK_TOOL_NAMES = frozenset( - {"todo", "session_search", "memory", "clarify", "delegate_task"} + {"todo", "session_search", "memory", "clarify", "read_terminal", "delegate_task"} ) @@ -1784,6 +1784,17 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i ), next_args, ) + elif function_name == "read_terminal": + def _execute(next_args: dict) -> Any: + from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool + return _finish_agent_tool( + _read_terminal_tool( + start_line=next_args.get("start_line"), + count=next_args.get("count"), + callback=getattr(agent, "read_terminal_callback", None), + ), + next_args, + ) elif function_name == "delegate_task": def _execute(next_args: dict) -> Any: return _finish_agent_tool(agent._dispatch_delegate_task(next_args), next_args) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 26fcfaae32f..cc62c13f9dd 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -885,6 +885,22 @@ def build_environment_hints() -> str: f"`uname -a && whoami && pwd`." ) + # Hermes desktop GUI — any agent running under the desktop app should know + # it. HERMES_DESKTOP marks the backend powering the chat; HERMES_DESKTOP_TERMINAL + # marks a hermes launched in the embedded terminal pane. Both set by main.cjs. + _truthy = ("1", "true", "yes") + _in_desktop = (os.getenv("HERMES_DESKTOP") or "").strip().lower() in _truthy + _in_desktop_term = (os.getenv("HERMES_DESKTOP_TERMINAL") or "").strip().lower() in _truthy + if _in_desktop or _in_desktop_term: + _desktop_hint = "Runtime surface: you're running inside the Hermes desktop GUI app." + if _in_desktop_term: + _desktop_hint += ( + " You're in its embedded terminal pane, beside the GUI chat — the user can " + "select your output (⌥-drag on macOS, Shift-drag elsewhere) and press " + "⌘/Ctrl+L to send it to the chat composer." + ) + hints.append(_desktop_hint) + if is_wsl(): hints.append(WSL_ENVIRONMENT_HINT) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 36cbad4b886..cd24b63f393 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1065,6 +1065,25 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe tool_duration = time.time() - tool_start_time if agent._should_emit_quiet_tool_messages(): agent._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}") + elif function_name == "read_terminal": + def _execute(next_args: dict) -> Any: + from tools.read_terminal_tool import read_terminal_tool as _read_terminal_tool + return _read_terminal_tool( + start_line=next_args.get("start_line"), + count=next_args.get("count"), + callback=getattr(agent, "read_terminal_callback", None), + ) + function_result, function_args = _run_agent_tool_execution_middleware( + agent, + function_name=function_name, + function_args=function_args, + effective_task_id=effective_task_id, + tool_call_id=getattr(tool_call, "id", "") or "", + execute=_execute, + ) + tool_duration = time.time() - tool_start_time + if agent._should_emit_quiet_tool_messages(): + agent._vprint(f" {_get_cute_tool_message_impl('read_terminal', function_args, tool_duration, result=function_result)}") elif function_name == "delegate_task": tasks_arg = function_args.get("tasks") if tasks_arg and isinstance(tasks_arg, list): diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 3ba6fbab2c8..52389beeed9 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -64,9 +64,11 @@ const { } = require('./hardening.cjs') let nodePty = null +let nodePtyDir = null try { nodePty = require('node-pty') + nodePtyDir = path.dirname(require.resolve('node-pty/package.json')) } catch { // Packaged builds set `files:` in package.json, which excludes node_modules // from the asar. Workspace dedup also hoists this native dep to the repo @@ -79,10 +81,12 @@ try { const path = require('node:path') const resourcesPath = process.resourcesPath if (resourcesPath) { - nodePty = require(path.join(resourcesPath, 'native-deps', 'node-pty')) + nodePtyDir = path.join(resourcesPath, 'native-deps', 'node-pty') + nodePty = require(nodePtyDir) } } catch { nodePty = null + nodePtyDir = null } } @@ -3272,14 +3276,18 @@ function setAndPersistZoomLevel(window, zoomLevel) { const next = clampZoomLevel(zoomLevel) window.webContents.setZoomLevel(next) window.webContents - .executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`) + .executeJavaScript( + `try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}` + ) .catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`)) } function restorePersistedZoomLevel(window) { if (!window || window.isDestroyed()) return window.webContents - .executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`) + .executeJavaScript( + `(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()` + ) .then(stored => { if (stored == null || !window || window.isDestroyed()) return const level = clampZoomLevel(Number(stored)) @@ -4138,9 +4146,7 @@ async function requestJsonForProfile(profile, path, method, body) { const conn = await ensureBackend(profile) const url = `${conn.baseUrl}${path}` const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS } - return conn.authMode === 'oauth' - ? fetchJsonViaOauthSession(url, opts) - : fetchJson(url, conn.token, opts) + return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts) } async function probeRemoteAuthMode(rawUrl) { @@ -4214,7 +4220,8 @@ async function testDesktopConnectionConfig(input = {}) { // The block under test: a per-profile entry or the global remote. Coerce has // already normalized the URL and resolved token inheritance for the scope. const block = key ? config.profiles?.[key] || null : config.remote - const wantRemote = block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) + const wantRemote = + block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block) // ``/api/status`` is public on every gateway (no creds needed), so a // reachability test works for local, token, and oauth modes alike — we only // need a base URL. For a remote config we normalize the URL from the input; @@ -4479,7 +4486,9 @@ async function spawnPoolBackend(profile, entry) { rememberLog(`Hermes backend for profile "${profile}" exited (${signal || code})`) backendPool.delete(profile) if (!ready) { - rejectStart?.(new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`)) + rejectStart?.( + new Error(`Hermes backend for profile "${profile}" exited before it became ready (${signal || code}).`) + ) } }) @@ -5249,17 +5258,19 @@ async function mergeRemoteProfileSessions(searchParams, remoteProfiles) { let total = (Number(base.total) || 0) - remoteProfiles.reduce((n, p) => n + (profileTotals[p] || 0), 0) // Swap each remote profile's stale local rows/total for the remote's real ones. - await Promise.all(remoteProfiles.map(async name => { - const list = await remoteSessionList(name, remoteParams).catch(() => null) - if (!list) { - delete profileTotals[name] // dead remote → drop its stale local total too - return - } - const rows = rowsOf(list) - merged.push(...rows) - profileTotals[name] = Number(list.total) || rows.length - total += profileTotals[name] - })) + await Promise.all( + remoteProfiles.map(async name => { + const list = await remoteSessionList(name, remoteParams).catch(() => null) + if (!list) { + delete profileTotals[name] // dead remote → drop its stale local total too + return + } + const rows = rowsOf(list) + merged.push(...rows) + profileTotals[name] = Number(list.total) || rows.length + total += profileTotals[name] + }) + ) const recency = s => s?.[order] ?? s?.started_at ?? 0 merged.sort((a, b) => recency(b) - recency(a)) @@ -5517,22 +5528,121 @@ function findGitRoot(start) { return null } -function terminalShellCommand() { - if (IS_WINDOWS) { - return { args: [], command: process.env.COMSPEC || 'cmd.exe' } +function isExecutableFile(filePath) { + if (!filePath || !path.isAbsolute(filePath)) { + return false } - const configuredShell = process.env.SHELL || '' - const shellPath = - (path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) || - ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) || - '/bin/sh' + try { + fs.accessSync(filePath, fs.constants.X_OK) + return true + } catch { + return false + } +} + +function posixShellSpec(shellPath) { const shellName = path.basename(shellPath) const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] return { args: interactiveArgs, command: shellPath, name: shellName } } +let spawnHelperChecked = false + +// node-pty execs a `spawn-helper` binary on macOS/Linux to launch the shell in a +// fresh session. The prebuilt that ships in node-pty's `prebuilds/` (and the +// staged copy under resources/native-deps) loses its execute bit through npm +// pack / electron-builder file collection, so every nodePty.spawn() dies with +// "posix_spawnp failed". Restore +x once, lazily, before the first spawn. +function ensureSpawnHelperExecutable() { + if (spawnHelperChecked || IS_WINDOWS || !nodePtyDir) { + return + } + + spawnHelperChecked = true + + const arch = process.arch + const candidates = [ + path.join(nodePtyDir, 'build', 'Release', 'spawn-helper'), + path.join(nodePtyDir, 'prebuilds', `${process.platform}-${arch}`, 'spawn-helper') + ] + + for (const helper of candidates) { + try { + const mode = fs.statSync(helper).mode + + if ((mode & 0o111) !== 0o111) { + fs.chmodSync(helper, mode | 0o755) + } + } catch { + // Not present in this layout (e.g. compiled build vs prebuild); skip. + } + } +} + +// Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; +// prefer it only after PowerShell 7+ (`pwsh`). +function windowsPowerShellPath() { + const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' + const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + + return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') +} + +// Map a resolved shell path to its spawn spec, picking interactive flags by +// family: PowerShell drops its logo banner (so the prompt sits flush like the +// POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) +// gets POSIX interactive-login flags. +function shellSpecFor(shellPath) { + const name = path.basename(shellPath).toLowerCase() + + if (name.startsWith('pwsh') || name.startsWith('powershell')) { + return { args: ['-NoLogo'], command: shellPath, name } + } + + if (name.startsWith('cmd')) { + return { args: [], command: shellPath, name } + } + + return posixShellSpec(shellPath) +} + +// Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell +// 5.1, then comspec/cmd.exe as the universal fallback. +function windowsShellSpec() { + const command = + findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' + + return shellSpecFor(command) +} + +// Resolve the interactive shell for the embedded terminal: an explicit user +// override wins, otherwise auto-detect the best one installed for the platform. +function terminalShellCommand() { + // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare + // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical + // choice, but ignored on Windows, where it's usually a stray MSYS/Git path + // node-pty can't spawn natively. + const override = (process.env.HERMES_DESKTOP_SHELL || (IS_WINDOWS ? '' : process.env.SHELL) || '').trim() + + if (override) { + const resolved = isExecutableFile(override) ? override : findOnPath(override) + + if (resolved) { + return shellSpecFor(resolved) + } + } + + if (IS_WINDOWS) { + return windowsShellSpec() + } + + const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) + + return posixShellSpec(shellPath || '/bin/sh') +} + function safeTerminalCwd(cwd) { const candidate = path.resolve(String(cwd || app.getPath('home'))) @@ -5570,6 +5680,11 @@ function terminalShellEnv() { env.TERM_PROGRAM = 'Hermes' env.TERM_PROGRAM_VERSION = app.getVersion() + // Let a hermes/--tui launched in this pane know it's embedded in the desktop + // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, + // which marks the agent *backend* and gates cron/gateway behavior. + env.HERMES_DESKTOP_TERMINAL = '1' + return env } @@ -5641,6 +5756,8 @@ ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') } + ensureSpawnHelperExecutable() + const id = crypto.randomUUID() const { args, command, name } = terminalShellCommand() const cwd = safeTerminalCwd(payload?.cwd) @@ -5970,7 +6087,6 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla // Search the Marketplace for color-theme extensions (empty query = top installs). ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20)) - app.whenReady().then(() => { if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7fbe9efa4a2..ed65795d1c4 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -4,6 +4,7 @@ import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons' +import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' @@ -62,6 +63,7 @@ export function ComposerControls({ }) { const { t } = useI18n() const c = t.composer + const steerLabel = `${c.steer} (${formatCombo('mod+enter')})` if (conversation.active) { return @@ -73,9 +75,9 @@ export function ComposerControls({
{canSteer && ( - + - - ) - })} - - - {branch && ( - - - {branch} - - )} -
- - ) -} - interface FilesystemTabProps extends FileTreeBodyProps { canCollapse: boolean cwdName: string diff --git a/apps/desktop/src/app/right-sidebar/store.ts b/apps/desktop/src/app/right-sidebar/store.ts index a560bfddafe..8c07f082450 100644 --- a/apps/desktop/src/app/right-sidebar/store.ts +++ b/apps/desktop/src/app/right-sidebar/store.ts @@ -2,14 +2,10 @@ import { atom } from 'nanostores' import { persistBoolean, storedBoolean } from '@/lib/storage' -export type RightSidebarTabId = 'files' | 'git' | 'terminal' | 'web' - const TAKEOVER_KEY = 'hermes.desktop.terminalTakeover' -export const $rightSidebarTab = atom('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) diff --git a/apps/desktop/src/app/right-sidebar/terminal/buffer.ts b/apps/desktop/src/app/right-sidebar/terminal/buffer.ts new file mode 100644 index 00000000000..df90d90875e --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/buffer.ts @@ -0,0 +1,65 @@ +import type { Terminal } from '@xterm/xterm' + +// Serialized view of the in-app terminal, handed to the agent's `read_terminal` +// tool. Line indices are absolute into xterm's buffer (0 = oldest scrollback +// line), so the agent can page with start_line/count against `total_lines`. +export interface TerminalReadResult { + total_lines: number + start: number + end: number + viewport_rows: number + cursor_row: number + text: string +} + +export interface TerminalReadOptions { + start?: number + count?: number +} + +type Reader = (opts: TerminalReadOptions) => TerminalReadResult + +// The persistent terminal is a singleton (one xterm mounted forever), so a +// module-level slot is enough — set while the session is live, cleared on +// dispose. The gateway `terminal.read.request` handler reads through this. +let activeReader: Reader | null = null + +export function setActiveTerminalReader(reader: Reader | null): void { + activeReader = reader +} + +export function readActiveTerminal(opts: TerminalReadOptions = {}): TerminalReadResult | null { + return activeReader ? activeReader(opts) : null +} + +export function makeTerminalReader(term: Terminal): Reader { + return ({ start, count }) => { + const buf = term.buffer.active + const total = buf.length + const rows = term.rows + // Default window = the visible screen; baseY is the viewport's top row. + const from = Math.max(0, Math.min(start ?? buf.baseY, total)) + const to = Math.max(from, Math.min(from + Math.max(1, count ?? rows), total)) + + const lines: string[] = [] + + // translateToString(true) right-trims and resolves wide chars, dropping SGR + // colors — exactly what the agent wants. + for (let i = from; i < to; i += 1) { + lines.push(buf.getLine(i)?.translateToString(true) ?? '') + } + + while (lines.length && !lines[lines.length - 1].trim()) { + lines.pop() + } + + return { + total_lines: total, + start: from, + end: to, + viewport_rows: rows, + cursor_row: buf.baseY + buf.cursorY, + text: lines.join('\n') + } + } +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/index.tsx b/apps/desktop/src/app/right-sidebar/terminal/index.tsx index f11a705300a..c3842366254 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/index.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/index.tsx @@ -1,7 +1,5 @@ import '@xterm/xterm/css/xterm.css' -import { useStore } from '@nanostores/react' - import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Loader } from '@/components/ui/loader' @@ -9,7 +7,7 @@ import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { SidebarPanelLabel } from '../../shell/sidebar-label' -import { $terminalTakeover, setRightSidebarTab, setTerminalTakeover } from '../store' +import { setTerminalTakeover } from '../store' import { addSelectionShortcutLabel } from './selection' import { useTerminalSession } from './use-terminal-session' @@ -21,41 +19,32 @@ interface TerminalTabProps { export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) { const { t } = useI18n() + const { addSelectionToChat, hostRef, selection, selectionStyle, shellName, status } = useTerminalSession({ cwd, onAddSelectionToChat }) - const takeover = useStore($terminalTakeover) - const label = takeover ? t.rightSidebar.terminalSplit : t.rightSidebar.terminalFocus - - const toggleTakeover = () => { - // Pre-select the Terminal tab so the slot is ready to host us on return. - if (takeover) { - setRightSidebarTab('terminal') - } - - setTerminalTakeover(!takeover) - } + const label = t.rightSidebar.terminalHide return (
- {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")