From 8f73d0d945d576eaf47e47a378d1985e91642c29 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Tue, 9 Jun 2026 23:15:20 -0500 Subject: [PATCH] feat(desktop): resizable VS Code-themed terminal pane + palette polish (#42521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(desktop): dock terminal under chat and simplify file rail Keep the right rail focused on file browsing while moving the persistent terminal into the chat column bottom slot, and make terminal colors follow the active light/dark mode instead of a fixed Solarized palette. * fix(desktop): make the terminal a resizable, themed side pane - Move the terminal into a resizable pane (viewport-% widths) that shares
's stacking context, so its drag handle no longer sits under the fixed terminal overlay; works on either rail side. - Restore +x on node-pty's spawn-helper before the first spawn to fix "posix_spawnp failed" on macOS prebuilds (real cause; drop the redundant shell-candidate retry loop). - Gate terminal open/fit/start on document.fonts.ready and strip leading blank rows (re-armed before the resize Ctrl-L redraw) so the prompt sits flush at the top with no starship add_newline gap. - Inherit the app editor-surface color as the terminal background. - Bind Ctrl+` (⌃` on macOS) to toggle the terminal; add a palette entry. * feat(desktop): show platform hotkey hints in the command palette - Render each palette item's live binding as a hint via a new comboTokens() helper (mac shows ⌘/⌃/⌥/⇧, every other platform shows Ctrl/Alt/Shift — never a ⌘ on PC). - Default the terminal toggle to ⌘` / Ctrl+` (the ~ key) on both platforms. - Drop the hardcoded (⌘⏎) baked into the composer steer tooltip; render it platform-aware with formatCombo instead. * fix(desktop): drop the active check on the command-palette terminal item * fix(desktop): remove active/check states from the command palette * fix(desktop): allow ⌥/Shift-drag selection over mouse-mode TUIs Full-screen apps (hermes --tui, vim) enable mouse reporting, so a plain drag can't select text and ⌘/Ctrl+L (add-selection-to-chat) had nothing to send. Enable macOptionClickForcesSelection so ⌥-drag on macOS (Shift elsewhere) forces a native selection over mouse-mode apps. * feat(desktop): tell the in-pane agent it's embedded in the GUI Set HERMES_DESKTOP_TERMINAL=1 on the terminal pane's shell env and surface it in build_environment_hints, so a hermes/--tui launched inside the pane knows it's next to the GUI chat and that ⌥/Shift-drag + ⌘/Ctrl+L sends a selection to the composer. Distinct from HERMES_DESKTOP (agent backend). * refactor(desktop): drop the redundant Ctrl+` terminal-toggle fallback The toggle now ships as mod+` on both platforms, so the standard combo index handles it — the bespoke fallback (and its stale 'old default' comment) is dead weight. * fix(desktop): read live terminal selection for ⌘/Ctrl+L A redraw-heavy TUI (spinners/clocks) outruns onSelectionChange, leaving the React selection state empty so the state-gated shortcut listener never attached and ⌘L no-op'd. Always listen and read xterm's live selection (with a native fallback) at press time; only swallow the key when there's text to send. Drops the now-redundant custom key handler. * feat(desktop): make any agent aware it's in the Hermes desktop GUI Generalize the runtime-surface hint: fire for HERMES_DESKTOP (the backend powering the GUI chat) as well as HERMES_DESKTOP_TERMINAL (a hermes in the embedded terminal pane), so it's about being inside the desktop GUI, not about being a TUI. The terminal-pane selection note stays pane-specific. * feat(desktop): give the GUI agent a read_terminal tool The in-app terminal buffer lives in the renderer (xterm), so expose it to the chat agent over the same blocking bridge clarify uses: read_terminal emits terminal.read.request, the renderer serializes the buffer (visible screen by default, or a start_line/count range against total_lines) and answers terminal.read.respond. Gated to the GUI via HERMES_DESKTOP. Also restores the flipped-layout titlebar inset fix (app-shell + desktop-controller) for terminal/preview rails at the window's left edge. * chore(desktop): trim read_terminal comments * feat(desktop): add a terminal toggle to the statusbar The file rail lost its terminal icon, leaving ⌘` and the command palette as the only ways in. Add a one-click toggle to the statusbar's left cluster, mirroring the command-center item: it reads $terminalTakeover so it lights up while the pane is open and stays in sync with the hotkey, and is gated to chat view (the only place the pane can show). * fix(desktop): relabel the terminal header button to what it does The in-pane button claimed a focus/split fullscreen toggle ("Focus terminal view" / "Return to split view", screen-full/normal icons), but the terminal is just a resizable side pane — there's no fullscreen. The button only mounts while the pane is open, so the focus branch was dead and clicking it merely closed the terminal. Relabel to "Hide terminal" with a close icon, drop the dead conditional and the now-unused takeover read. * fix(desktop): move the terminal toggle next to the version item Relocate it from the left cluster to the right of the statusbar, just left of the client version item. * feat(desktop): default the terminal to PowerShell on Windows Prefer pwsh (7+) then Windows PowerShell 5.1 over cmd.exe, falling back to comspec only when neither is present. -NoLogo drops the startup banner so the prompt sits flush like the POSIX shells. * feat(desktop): show a persistent divider on the terminal pane The resize sash only painted on hover, so the terminal/chat boundary was invisible at rest. Add an opt-in `divider` prop to Pane that paints a thin resting hairline on the resize edge (side-aware, so it tracks the rail when the layout flips) and enable it on the terminal pane. * refactor(desktop): resolve the terminal shell instead of hardcoding it Make shell selection a real resolver: an explicit override wins (HERMES_DESKTOP_SHELL on both platforms, $SHELL on POSIX), otherwise auto-detect the best installed shell — pwsh > Windows PowerShell 5.1 > cmd on Windows, zsh > bash > sh on POSIX. A shared shellSpecFor() picks the interactive flags by family, so an overridden bash/pwsh/cmd all launch correctly. * fix(desktop): repaint the terminal on light/dark switch Setting term.options.theme updated colors for the DOM renderer but not the WebGL one, which caches glyph colors in a texture atlas — so already-drawn cells kept their old palette after a mode switch. Hold the WebglAddon in a ref and clear its atlas when the theme changes. * fix(desktop): match the terminal palette to VS Code Light+/Dark+ Adopt VS Code's exact default ANSI palette (the terminalColorRegistry defaults), enable minimumContrastRatio: 4.5 so foregrounds are clamped against the background the way the integrated terminal does, and key the light/dark choice off renderedMode (the painted surface) instead of resolvedMode so it can't invert. The canvas + inset paint the live skin surface (--ui-editor-surface-background) so the terminal blends with the app and follows light/dark, while the contrast clamp keeps colors crisp. * fix(desktop): tighten command palette search to substring matching cmdk's default fuzzy scorer matched anything with the query letters scattered across an item, so e.g. "color" never narrowed to color entries. Add a substring filter: every typed word must literally appear in an item's value/keywords, keeping results tight and predictable. * fix(desktop): blend the terminal header into the skin surface The persistent-terminal overlay painted the static palette background (#1e1e1e/#ffffff), so the transparent header strip revealed a near-black slab above the surface-colored body. Paint the overlay with the live --ui-editor-surface-background so header and body read as one pane. * fix(desktop): re-resolve the terminal surface on skin switch The canvas surface only re-resolved on light/dark change, so switching skins at the same mode left the WebGL canvas painted with the old tint until reload. Key the resolve off themeName too. Also trim the palette comments. * chore(desktop): drop redundant terminal theming header comment --- agent/agent_init.py | 2 + agent/agent_runtime_helpers.py | 13 +- agent/prompt_builder.py | 16 + agent/tool_executor.py | 19 + apps/desktop/electron/main.cjs | 172 +++++++-- .../src/app/chat/composer/controls.tsx | 6 +- apps/desktop/src/app/chat/index.tsx | 5 +- .../desktop/src/app/command-palette/index.tsx | 102 ++++- apps/desktop/src/app/desktop-controller.tsx | 99 +++-- apps/desktop/src/app/hooks/use-keybinds.ts | 10 +- apps/desktop/src/app/right-sidebar/index.tsx | 117 ++---- apps/desktop/src/app/right-sidebar/store.ts | 4 - .../src/app/right-sidebar/terminal/buffer.ts | 65 ++++ .../src/app/right-sidebar/terminal/index.tsx | 38 +- .../app/right-sidebar/terminal/persistent.tsx | 6 +- .../app/right-sidebar/terminal/selection.ts | 101 +++-- .../terminal/use-terminal-session.ts | 348 +++++++++++++----- .../app/session/hooks/use-message-stream.ts | 17 + apps/desktop/src/app/shell/app-shell.tsx | 31 +- .../app/shell/hooks/use-statusbar-items.tsx | 16 + .../src/components/pane-shell/pane-shell.tsx | 26 +- apps/desktop/src/i18n/en.ts | 7 +- apps/desktop/src/i18n/ja.ts | 5 +- apps/desktop/src/i18n/types.ts | 5 +- apps/desktop/src/i18n/zh-hant.ts | 5 +- apps/desktop/src/i18n/zh.ts | 7 +- apps/desktop/src/lib/chat-messages.ts | 3 + apps/desktop/src/lib/keybinds/actions.ts | 15 +- apps/desktop/src/lib/keybinds/combo.ts | 60 +-- apps/desktop/src/themes/context.tsx | 28 +- run_agent.py | 2 + tools/read_terminal_tool.py | 93 +++++ toolsets.py | 3 + tui_gateway/server.py | 14 + 34 files changed, 1058 insertions(+), 402 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/buffer.ts create mode 100644 tools/read_terminal_tool.py 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 dab99e37404..fdc2d63832b 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -63,9 +63,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 @@ -78,10 +80,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 } } @@ -3271,14 +3275,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)) @@ -4137,9 +4145,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) { @@ -4213,7 +4219,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; @@ -4478,7 +4485,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}).`) + ) } }) @@ -5248,17 +5257,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)) @@ -5516,22 +5527,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'))) @@ -5569,6 +5679,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 } @@ -5640,6 +5755,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) @@ -5962,7 +6079,6 @@ ipcMain.handle('hermes:uninstall:run', async (_event, payload) => { return runDesktopUninstall(String(mode || '')) }) - 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..04be824b11b 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/selection.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/selection.ts @@ -1,38 +1,79 @@ 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' - -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. `background` is only a fallback — withSurface swaps +// in the live skin surface at runtime; minimumContrastRatio keeps colors crisp. +export const terminalTheme = (mode: 'light' | 'dark'): ITheme => (mode === 'dark' ? DARK_THEME : LIGHT_THEME) + +// 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..ae272e68b77 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,16 @@ 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, themeName } = useTheme() + const activeTheme = useMemo(() => terminalTheme(renderedMode), [renderedMode]) + 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 +243,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 +270,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 +289,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 +313,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 +335,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 +379,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 +464,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 +501,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 +523,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 +615,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 ccefe464c7e..1915591d5c0 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1130,7 +1130,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', @@ -1471,6 +1471,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', @@ -1526,8 +1528,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 0843d074a2d..37c3bca878c 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1605,6 +1605,8 @@ export const ja = defineLocale({ branch: branch => `ブランチ ${branch}`, closeCommandCenter: 'コマンドセンターを閉じる', openCommandCenter: 'コマンドセンターを開く', + showTerminal: 'ターミナルを表示', + hideTerminal: 'ターミナルを非表示', gateway: 'ゲートウェイ', gatewayReady: '準備完了', gatewayNeedsSetup: '設定が必要', @@ -1660,8 +1662,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 16d1a08d352..8206a80f9bd 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1134,6 +1134,8 @@ export interface Translations { branch: (branch: string) => string closeCommandCenter: string openCommandCenter: string + showTerminal: string + hideTerminal: string gateway: string gatewayReady: string gatewayNeedsSetup: string @@ -1189,8 +1191,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 821144be67b..1e19ec3b9ed 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1566,6 +1566,8 @@ export const zhHant = defineLocale({ branch: branch => `分支 ${branch}`, closeCommandCenter: '關閉命令中心', openCommandCenter: '開啟命令中心', + showTerminal: '顯示終端機', + hideTerminal: '隱藏終端機', gateway: '閘道', gatewayReady: '就緒', gatewayNeedsSetup: '需要設定', @@ -1621,8 +1623,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 55b86dc1517..d3f24c95e7d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1317,7 +1317,7 @@ export const zh: Translations = { ], startVoice: '开始语音对话', queueMessage: '排队消息', - steer: '引导当前运行 (⌘⏎)', + steer: '引导当前运行', stop: '停止', send: '发送', speaking: '讲话中', @@ -1652,6 +1652,8 @@ export const zh: Translations = { branch: branch => `分支 ${branch}`, closeCommandCenter: '关闭命令中心', openCommandCenter: '打开命令中心', + showTerminal: '显示终端', + hideTerminal: '隐藏终端', gateway: '网关', gatewayReady: '就绪', gatewayNeedsSetup: '需要设置', @@ -1707,8 +1709,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 0f117213819..de920bd7d3d 100644 --- a/apps/desktop/src/themes/context.tsx +++ b/apps/desktop/src/themes/context.tsx @@ -286,7 +286,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 @@ -299,6 +307,7 @@ const ThemeContext = createContext({ themeName: DEFAULT_SKIN_NAME, mode: 'light', resolvedMode: 'light', + renderedMode: 'light', availableThemes: SKIN_LIST, setTheme: () => {}, setMode: () => {} @@ -330,6 +339,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 @@ -351,8 +366,17 @@ 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: SKIN_LIST, setTheme, setMode }), - [activeTheme, themeName, mode, resolvedMode, setTheme, setMode] + () => ({ + theme: activeTheme, + themeName, + mode, + resolvedMode, + renderedMode, + availableThemes: SKIN_LIST, + setTheme, + setMode + }), + [activeTheme, themeName, mode, resolvedMode, renderedMode, setTheme, setMode] ) return {children} 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")