mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
feat(desktop): resizable VS Code-themed terminal pane + palette polish (#42521)
* 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
<main>'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 <KbdGroup> 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
This commit is contained in:
parent
5cf6e28a2f
commit
8f73d0d945
34 changed files with 1058 additions and 402 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 <ConversationPill {...conversation} disabled={disabled} />
|
||||
|
|
@ -73,9 +75,9 @@ export function ComposerControls({
|
|||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<Tip label={c.steer}>
|
||||
<Tip label={steerLabel}>
|
||||
<Button
|
||||
aria-label={c.steer}
|
||||
aria-label={steerLabel}
|
||||
className={GHOST_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={onSteer}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,10 @@ function ChatHeader({
|
|||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div
|
||||
className="min-w-0 flex-1"
|
||||
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
|
||||
style={{
|
||||
maxWidth:
|
||||
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
|
||||
}}
|
||||
>
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { KbdGroup } from '@/components/ui/kbd'
|
||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
|
|
@ -12,7 +14,6 @@ import {
|
|||
Activity,
|
||||
Archive,
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
|
|
@ -30,12 +31,15 @@ import {
|
|||
Settings,
|
||||
Settings2,
|
||||
Sun,
|
||||
Terminal,
|
||||
Users,
|
||||
Wrench,
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { comboTokens } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
|
||||
import {
|
||||
|
|
@ -55,7 +59,8 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
|||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
interface PaletteItem {
|
||||
active?: boolean
|
||||
/** Keybind action id — its live combo renders as a hotkey hint. */
|
||||
action?: string
|
||||
icon: IconComponent
|
||||
id: string
|
||||
/** Keep the palette open after running (live-preview pickers like theme/mode). */
|
||||
|
|
@ -86,6 +91,22 @@ interface SessionEntry {
|
|||
title: string
|
||||
}
|
||||
|
||||
// cmdk defaults to fuzzy subsequence scoring, so "color" matches anything with
|
||||
// c…o…l…o…r scattered across it. Use case-insensitive multi-term substring
|
||||
// matching instead: every typed word must literally appear in the item's
|
||||
// value/keywords, which keeps results tight and predictable.
|
||||
const paletteFilter = (value: string, search: string, keywords?: string[]): number => {
|
||||
const needle = search.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const haystack = `${value} ${keywords?.join(' ') ?? ''}`.toLowerCase()
|
||||
|
||||
return needle.split(/\s+/).every(term => haystack.includes(term)) ? 1 : 0
|
||||
}
|
||||
|
||||
type SessionRow = Awaited<ReturnType<typeof listSessions>>['sessions'][number]
|
||||
|
||||
const toSessionEntry = (session: SessionRow): SessionEntry => ({
|
||||
|
|
@ -149,8 +170,9 @@ const THEME_MODES: ReadonlyArray<{ icon: IconComponent; mode: ThemeMode }> = [
|
|||
export function CommandPalette() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const bindings = useStore($bindings)
|
||||
const navigate = useNavigate()
|
||||
const { availableThemes, mode, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
const { availableThemes, setMode, setTheme } = useTheme()
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -194,10 +216,12 @@ export function CommandPalette() {
|
|||
}, [open])
|
||||
|
||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
||||
|
||||
const settingsSectionLabel = useCallback(
|
||||
(section: (typeof SECTIONS)[number]) => t.settings.sections[section.id] ?? section.label,
|
||||
[t.settings.sections]
|
||||
)
|
||||
|
||||
const configFieldLabel = useCallback(
|
||||
(key: string) =>
|
||||
fieldCopyForSchemaKey(t.settings.fieldLabels, key) ??
|
||||
|
|
@ -214,20 +238,61 @@ export function CommandPalette() {
|
|||
{
|
||||
heading: cc.goTo,
|
||||
items: [
|
||||
{ icon: Plus, id: 'nav-new', keywords: ['chat', 'create'], label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) },
|
||||
{ icon: Settings, id: 'nav-settings', label: cc.nav.settings.title, run: go(SETTINGS_ROUTE) },
|
||||
{
|
||||
action: 'session.new',
|
||||
icon: Plus,
|
||||
id: 'nav-new',
|
||||
keywords: ['chat', 'create'],
|
||||
label: cc.nav.newChat.title,
|
||||
run: go(NEW_CHAT_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'view.showTerminal',
|
||||
icon: Terminal,
|
||||
id: 'nav-terminal',
|
||||
keywords: ['terminal', 'shell', 'console'],
|
||||
label: t.keybinds.actions['view.showTerminal'],
|
||||
run: () => setTerminalTakeover(true)
|
||||
},
|
||||
{
|
||||
action: 'nav.settings',
|
||||
icon: Settings,
|
||||
id: 'nav-settings',
|
||||
label: cc.nav.settings.title,
|
||||
run: go(SETTINGS_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'nav.skills',
|
||||
icon: Wrench,
|
||||
id: 'nav-skills',
|
||||
keywords: ['tools', 'toolsets'],
|
||||
label: cc.nav.skills.title,
|
||||
run: go(SKILLS_ROUTE)
|
||||
},
|
||||
{ icon: MessageCircle, id: 'nav-messaging', label: cc.nav.messaging.title, run: go(MESSAGING_ROUTE) },
|
||||
{ icon: Package, id: 'nav-artifacts', label: cc.nav.artifacts.title, run: go(ARTIFACTS_ROUTE) },
|
||||
{ icon: Clock, id: 'nav-cron', keywords: ['schedule', 'jobs'], label: t.shell.statusbar.cron, run: go(CRON_ROUTE) },
|
||||
{ icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
|
||||
{ icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
||||
{
|
||||
action: 'nav.messaging',
|
||||
icon: MessageCircle,
|
||||
id: 'nav-messaging',
|
||||
label: cc.nav.messaging.title,
|
||||
run: go(MESSAGING_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'nav.artifacts',
|
||||
icon: Package,
|
||||
id: 'nav-artifacts',
|
||||
label: cc.nav.artifacts.title,
|
||||
run: go(ARTIFACTS_ROUTE)
|
||||
},
|
||||
{
|
||||
action: 'nav.cron',
|
||||
icon: Clock,
|
||||
id: 'nav-cron',
|
||||
keywords: ['schedule', 'jobs'],
|
||||
label: t.shell.statusbar.cron,
|
||||
run: go(CRON_ROUTE)
|
||||
},
|
||||
{ action: 'nav.profiles', icon: Users, id: 'nav-profiles', label: t.profiles.title, run: go(PROFILES_ROUTE) },
|
||||
{ action: 'nav.agents', icon: Cpu, id: 'nav-agents', label: t.agents.title, run: go(AGENTS_ROUTE) }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -379,7 +444,6 @@ export function CommandPalette() {
|
|||
groups: (['light', 'dark'] as const).map(groupMode => ({
|
||||
heading: groupMode === 'light' ? t.settings.modeOptions.light.label : t.settings.modeOptions.dark.label,
|
||||
items: availableThemes.map(theme => ({
|
||||
active: themeName === theme.name && resolvedMode === groupMode,
|
||||
icon: groupMode === 'light' ? Sun : Moon,
|
||||
id: `theme-${theme.name}-${groupMode}`,
|
||||
keepOpen: true,
|
||||
|
|
@ -399,7 +463,6 @@ export function CommandPalette() {
|
|||
{
|
||||
heading: t.settings.appearance.colorMode,
|
||||
items: THEME_MODES.map(entry => ({
|
||||
active: mode === entry.mode,
|
||||
icon: entry.icon,
|
||||
id: `mode-${entry.mode}`,
|
||||
keepOpen: true,
|
||||
|
|
@ -411,7 +474,7 @@ export function CommandPalette() {
|
|||
]
|
||||
}
|
||||
}),
|
||||
[availableThemes, mode, resolvedMode, setMode, setTheme, t, themeName]
|
||||
[availableThemes, setMode, setTheme, t]
|
||||
)
|
||||
|
||||
const activePage = page ? subPages[page] : null
|
||||
|
|
@ -442,7 +505,7 @@ export function CommandPalette() {
|
|||
className="fixed left-1/2 top-[14vh] z-[210] w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-lg duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<DialogPrimitive.Title className="sr-only">{t.commandCenter.paletteTitle}</DialogPrimitive.Title>
|
||||
<Command className="bg-transparent" loop>
|
||||
<Command className="bg-transparent" filter={paletteFilter} loop>
|
||||
{activePage && (
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
|
|
@ -483,6 +546,8 @@ export function CommandPalette() {
|
|||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
const keys = combo ? comboTokens(combo) : null
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
|
|
@ -494,10 +559,11 @@ export function CommandPalette() {
|
|||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.to ? (
|
||||
<ChevronRight className="ml-auto size-4 shrink-0 text-muted-foreground/70" />
|
||||
) : (
|
||||
<Check className={cn('ml-auto size-4 text-foreground', !item.active && 'invisible')} />
|
||||
{keys && <KbdGroup className="ml-auto" keys={keys} />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-4 shrink-0 text-muted-foreground/70', !keys && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,9 @@ const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
|
|||
// Cheap signature compare so the poll only swaps the atom (and re-renders the
|
||||
// sidebar) when the visible cron rows actually changed.
|
||||
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||
if (a.length !== b.length) {return false}
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
|
||||
}
|
||||
|
|
@ -223,7 +225,7 @@ export function DesktopController() {
|
|||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
||||
const terminalSidebarOpen = chatOpen && terminalTakeover
|
||||
|
||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||
const statusbarItemGroups = useGroupRegistry<StatusbarItem>()
|
||||
|
|
@ -420,7 +422,10 @@ export function DesktopController() {
|
|||
|
||||
const keep = sessionsToKeep(key)
|
||||
|
||||
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
|
||||
setSessions(prev => [
|
||||
...prev.filter(s => !inKey(s)),
|
||||
...mergeSessionPage(prev.filter(inKey), result.sessions, keep)
|
||||
])
|
||||
|
||||
const total = result.profile_totals?.[key] ?? result.total ?? result.sessions.length
|
||||
setSessionProfileTotals(prev => ({ ...prev, [key]: Math.max(total, result.sessions.length) }))
|
||||
|
|
@ -681,19 +686,19 @@ export function DesktopController() {
|
|||
submitText,
|
||||
transcribeVoiceAudio
|
||||
} = usePromptActions({
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: branchInNewChat,
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
sttEnabled,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||
|
|
@ -719,10 +724,14 @@ export function DesktopController() {
|
|||
// in the background (advancing next-run/state and creating runs), so poll the
|
||||
// job list on an interval (and on tab re-focus) while connected.
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {return}
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (document.visibilityState === 'visible') {void refreshCronJobs()}
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refreshCronJobs()
|
||||
}
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
|
||||
|
|
@ -752,6 +761,7 @@ export function DesktopController() {
|
|||
|
||||
const { leftStatusbarItems, statusbarItems } = useStatusbarItems({
|
||||
agentsOpen,
|
||||
chatOpen,
|
||||
commandCenterOpen,
|
||||
extraLeftItems: statusbarItemGroups.flat.left,
|
||||
extraRightItems: statusbarItemGroups.flat.right,
|
||||
|
|
@ -790,12 +800,16 @@ export function DesktopController() {
|
|||
/>
|
||||
)
|
||||
|
||||
// One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders decide
|
||||
// where it shows. Lives in main's stacking context (not the root overlay layer)
|
||||
// so pane resize handles still paint above it. Toggling never rebuilds the shell.
|
||||
const mainOverlays = (
|
||||
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
)
|
||||
|
||||
const overlays = (
|
||||
<>
|
||||
{!isSecondaryWindow() && <DesktopInstallOverlay />}
|
||||
{/* One PTY-backed terminal mounted forever; <TerminalSlot /> placeholders
|
||||
decide where it shows. Toggling fullscreen never rebuilds the shell. */}
|
||||
<PersistentTerminal cwd={currentCwd} onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
|
||||
{!isSecondaryWindow() && (
|
||||
<DesktopOnboardingOverlay
|
||||
enabled={gatewayState === 'open'}
|
||||
|
|
@ -901,12 +915,6 @@ export function DesktopController() {
|
|||
/>
|
||||
)
|
||||
|
||||
const takeoverTerminalView = (
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background) pt-(--titlebar-height)">
|
||||
<TerminalSlot />
|
||||
</div>
|
||||
)
|
||||
|
||||
// Flipped layout mirrors the default: sessions sidebar → right, file
|
||||
// browser + preview rail → left. Same panes, swapped sides.
|
||||
const sidebarSide = panesFlipped ? 'right' : 'left'
|
||||
|
|
@ -951,18 +959,39 @@ export function DesktopController() {
|
|||
</Pane>
|
||||
)
|
||||
|
||||
const terminalPane = (
|
||||
<Pane
|
||||
defaultOpen
|
||||
disabled={!terminalSidebarOpen}
|
||||
divider
|
||||
id="terminal-sidebar"
|
||||
key="terminal-sidebar"
|
||||
maxWidth="80vw"
|
||||
minWidth="22vw"
|
||||
resizable
|
||||
side={railSide}
|
||||
width="42vw"
|
||||
>
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-(--ui-editor-surface-background) pt-(--titlebar-height)">
|
||||
<TerminalSlot />
|
||||
</div>
|
||||
</Pane>
|
||||
)
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
leftStatusbarItems={leftStatusbarItems}
|
||||
leftTitlebarTools={titlebarToolGroups.flat.left}
|
||||
mainOverlays={mainOverlays}
|
||||
onOpenSettings={openSettings}
|
||||
overlays={overlays}
|
||||
previewPaneOpen={chatOpen && Boolean(previewTarget || filePreviewTarget)}
|
||||
statusbarItems={statusbarItems}
|
||||
terminalPaneOpen={terminalSidebarOpen}
|
||||
titlebarTools={titlebarToolGroups.flat.right}
|
||||
>
|
||||
{!isSecondaryWindow() && (
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
forceCollapsed={narrowViewport}
|
||||
hoverReveal
|
||||
id="chat-sidebar"
|
||||
|
|
@ -978,8 +1007,8 @@ export function DesktopController() {
|
|||
)}
|
||||
<PaneMain>
|
||||
<Routes>
|
||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} index />
|
||||
<Route element={terminalTakeoverActive ? takeoverTerminalView : chatView} path=":sessionId" />
|
||||
<Route element={chatView} index />
|
||||
<Route element={chatView} path=":sessionId" />
|
||||
<Route
|
||||
element={
|
||||
<Suspense fallback={null}>
|
||||
|
|
@ -1016,11 +1045,13 @@ export function DesktopController() {
|
|||
</PaneMain>
|
||||
{/*
|
||||
Order within a side maps to column order. Default (rail on the right):
|
||||
main | preview | file-browser. Flipped (rail on the left): mirror it to
|
||||
file-browser | preview | main so preview stays adjacent to the chat.
|
||||
main | terminal | preview | file-browser. Flipped (rail on the left):
|
||||
mirror to file-browser | preview | terminal | main so terminal stays
|
||||
adjacent to the chat.
|
||||
*/}
|
||||
{panesFlipped ? fileBrowserPane : previewPane}
|
||||
{panesFlipped ? previewPane : fileBrowserPane}
|
||||
{panesFlipped ? fileBrowserPane : terminalPane}
|
||||
{previewPane}
|
||||
{panesFlipped ? terminalPane : fileBrowserPane}
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { setRightSidebarTab } from '@/app/right-sidebar/store'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
|
||||
|
|
@ -103,9 +103,9 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
goToSession(openOrAdvanceSwitcher(direction))
|
||||
}
|
||||
|
||||
const showRightSidebarTab = (tab: 'files' | 'terminal') => {
|
||||
const showFiles = () => {
|
||||
setFileBrowserOpen(true)
|
||||
setRightSidebarTab(tab)
|
||||
setTerminalTakeover(false)
|
||||
}
|
||||
|
||||
handlersRef.current = {
|
||||
|
|
@ -152,8 +152,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
toggleFileBrowserOpen()
|
||||
}
|
||||
},
|
||||
'view.showFiles': () => showRightSidebarTab('files'),
|
||||
'view.showTerminal': () => showRightSidebarTab('terminal'),
|
||||
'view.showFiles': showFiles,
|
||||
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
|
||||
'view.flipPanes': togglePanesFlipped,
|
||||
|
||||
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
|
||||
|
|
|
|||
|
|
@ -4,22 +4,20 @@ import type { ReactNode } from 'react'
|
|||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $panesFlipped } from '@/store/layout'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { setCurrentSessionPreviewTarget } from '@/store/preview'
|
||||
import { $currentBranch, $currentCwd } from '@/store/session'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
import { SidebarPanelLabel } from '../shell/sidebar-label'
|
||||
|
||||
import { ProjectTree } from './files/tree'
|
||||
import { useProjectTree } from './files/use-project-tree'
|
||||
import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store'
|
||||
import { TerminalSlot } from './terminal/persistent'
|
||||
|
||||
interface RightSidebarPaneProps {
|
||||
onActivateFile: (path: string) => void
|
||||
|
|
@ -27,24 +25,10 @@ interface RightSidebarPaneProps {
|
|||
onChangeCwd: (path: string) => Promise<void> | void
|
||||
}
|
||||
|
||||
interface RightSidebarTab {
|
||||
icon: string
|
||||
id: RightSidebarTabId
|
||||
labelKey: 'files' | 'terminal'
|
||||
}
|
||||
|
||||
const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [
|
||||
{ id: 'files', labelKey: 'files', icon: 'list-tree' },
|
||||
{ id: 'terminal', labelKey: 'terminal', icon: 'terminal' }
|
||||
]
|
||||
|
||||
export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd }: RightSidebarPaneProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
const activeTab = useStore($rightSidebarTab)
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
const currentBranch = useStore($currentBranch).trim()
|
||||
const currentCwd = useStore($currentCwd).trim()
|
||||
const hasCwd = currentCwd.length > 0
|
||||
|
||||
|
|
@ -68,7 +52,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||
} = useProjectTree(currentCwd)
|
||||
|
||||
const canCollapse = Object.values(openState).some(Boolean)
|
||||
const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab
|
||||
|
||||
const chooseFolder = async () => {
|
||||
const selected = await window.hermesDesktop?.selectPaths({
|
||||
|
|
@ -97,8 +80,6 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||
}
|
||||
}
|
||||
|
||||
const tabs = terminalTakeover ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') : RIGHT_SIDEBAR_TABS
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-label={r.aria}
|
||||
|
|
@ -109,85 +90,29 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
|
|||
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
|
||||
)}
|
||||
>
|
||||
<RightSidebarChrome activeTab={effectiveTab} branch={currentBranch} tabs={tabs} />
|
||||
|
||||
{effectiveTab === 'terminal' ? (
|
||||
<TerminalSlot />
|
||||
) : (
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
)}
|
||||
<FilesystemTab
|
||||
canCollapse={canCollapse}
|
||||
collapseNonce={collapseNonce}
|
||||
cwd={currentCwd}
|
||||
cwdName={cwdName}
|
||||
data={data}
|
||||
error={rootError}
|
||||
hasCwd={hasCwd}
|
||||
loading={rootLoading}
|
||||
onActivateFile={onActivateFile}
|
||||
onActivateFolder={onActivateFolder}
|
||||
onChangeFolder={chooseFolder}
|
||||
onCollapseAll={collapseAll}
|
||||
onLoadChildren={loadChildren}
|
||||
onNodeOpenChange={setNodeOpen}
|
||||
onPreviewFile={previewFile}
|
||||
onRefresh={() => void refreshRoot()}
|
||||
openState={openState}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function RightSidebarChrome({
|
||||
activeTab,
|
||||
branch,
|
||||
tabs
|
||||
}: {
|
||||
activeTab: RightSidebarTabId
|
||||
branch: string
|
||||
tabs: readonly RightSidebarTab[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const r = t.rightSidebar
|
||||
|
||||
return (
|
||||
<header className="shrink-0 bg-transparent text-[0.75rem]">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1">
|
||||
<nav aria-label={r.panelsAria} className="flex min-w-0 items-center gap-1">
|
||||
{tabs.map(tab => {
|
||||
const label = r[tab.labelKey]
|
||||
|
||||
return (
|
||||
<Tip key={tab.id} label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={tab.id === activeTab}
|
||||
className={cn(
|
||||
'text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground',
|
||||
tab.id === activeTab && 'bg-(--ui-control-active-background) text-foreground'
|
||||
)}
|
||||
onClick={() => setRightSidebarTab(tab.id)}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={tab.icon} size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{branch && (
|
||||
<span className="ml-auto flex min-w-0 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<Codicon className="shrink-0" name="git-branch" size="0.75rem" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
canCollapse: boolean
|
||||
cwdName: string
|
||||
|
|
|
|||
|
|
@ -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<RightSidebarTabId>('files')
|
||||
export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
||||
|
||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||
|
||||
export const setRightSidebarTab = (tab: RightSidebarTabId) => $rightSidebarTab.set(tab)
|
||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||
|
|
|
|||
65
apps/desktop/src/app/right-sidebar/terminal/buffer.ts
Normal file
65
apps/desktop/src/app/right-sidebar/terminal/buffer.ts
Normal file
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 px-2.5">
|
||||
<SidebarPanelLabel className="text-white!">{shellName}</SidebarPanelLabel>
|
||||
<SidebarPanelLabel className="text-(--ui-text-secondary)!">{shellName}</SidebarPanelLabel>
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
className="ml-auto size-6 rounded-md text-white!"
|
||||
onClick={toggleTakeover}
|
||||
className="ml-auto size-6 rounded-md text-(--ui-text-secondary)!"
|
||||
onClick={() => setTerminalTakeover(false)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={takeover ? 'screen-normal' : 'screen-full'} size="0.875rem" />
|
||||
<Codicon name="close" size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 bg-[#002b36] p-2">
|
||||
<div className="relative min-h-0 flex-1 bg-(--ui-editor-surface-background) p-2">
|
||||
{status === 'starting' && (
|
||||
<div className="pointer-events-none absolute inset-0 z-10 grid place-items-center">
|
||||
<Loader
|
||||
|
|
@ -84,12 +73,13 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Outer div paints the dark inset; inner div is the xterm host so the
|
||||
canvas sizes to the *content* area and p-2 shows as terminal padding.
|
||||
Forcing screen/viewport bg avoids xterm's default black peeking
|
||||
through the unused pixels below the last full row. */}
|
||||
{/* 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. */}
|
||||
<div
|
||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-[#002b36]! [&_.xterm-viewport]:bg-[#002b36]!"
|
||||
className="h-full min-h-0 overflow-hidden text-(--ui-text-secondary) [&_.xterm]:h-full [&_.xterm-screen]:bg-(--ui-editor-surface-background)! [&_.xterm-viewport]:bg-(--ui-editor-surface-background)!"
|
||||
ref={hostRef}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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<typeof terminalTheme>) {
|
||||
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<HTMLDivElement | null>(null)
|
||||
const termRef = useRef<Terminal | null>(null)
|
||||
const webglRef = useRef<WebglAddon | null>(null)
|
||||
const sessionIdRef = useRef<string | null>(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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -29,9 +29,19 @@ interface AppShellProps {
|
|||
children: ReactNode
|
||||
leftStatusbarItems?: readonly StatusbarItem[]
|
||||
leftTitlebarTools?: readonly TitlebarTool[]
|
||||
// Fixed-position overlays that must share <main>'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 <main>, 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}
|
||||
</PaneShell>
|
||||
|
||||
{/* 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}
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
</main>
|
||||
|
||||
|
|
|
|||
|
|
@ -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: <Terminal className="size-3.5" />,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 && <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-(--ui-stroke-secondary)" />}
|
||||
<span className="absolute inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2 bg-(--ui-sash-hover-border) opacity-0 transition-opacity duration-100 group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 'チャットに追加'
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: '新增至聊天'
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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: '添加到对话'
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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 <KbdGroup>.
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ThemeContextValue>({
|
|||
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<ThemeContextValue>(
|
||||
() => ({ 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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
93
tools/read_terminal_tool.py
Normal file
93
tools/read_terminal_tool.py
Normal file
|
|
@ -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="🖥️",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue