From ad831dd4928e817bbb4b913561ec5ee09e0672b9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 19:26:21 -0500 Subject: [PATCH] feat(desktop): mirror agent background terminals as read-only tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the agent runs terminal(background=true) — Hermes's equivalent of Cursor's is_background — surface it as a read-only "agent" tab in the rail (distinct sparkle icon), alongside the glanceable status-stack row, which now links to the tab. The tab is a write-only xterm (no PTY, no input) fed by the process output tail, appended live (faster poll while a tab is open) and env-agnostic (works for local/docker/ssh shells alike). - terminals.ts: TerminalEntry gains kind ('user'|'agent') + procId; agent tabs auto-surface once (closing one doesn't resurrect it) and the status row can reopen/focus them. ensureTerminal now guarantees a user shell specifically. - use-agent-terminal.ts: slim read-only xterm hook, delta-appended. - workspace: render user vs agent instances; auto-surface from the background store; tail faster while an agent tab exists. - composer-status: $backgroundOutputByProc selector; status row links to the tab instead of an inline disclosure. --- .../chat/composer/status-stack/status-row.tsx | 14 +- .../app/right-sidebar/terminal/instance.tsx | 39 +++-- .../src/app/right-sidebar/terminal/rail.tsx | 6 +- .../app/right-sidebar/terminal/terminals.ts | 52 ++++++- .../terminal/use-agent-terminal.ts | 142 ++++++++++++++++++ .../app/right-sidebar/terminal/workspace.tsx | 54 +++++-- apps/desktop/src/store/composer-status.ts | 16 ++ 7 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts diff --git a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx index bc54b92ffe9..68962cb7295 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/status-row.tsx @@ -1,10 +1,9 @@ -import { Fragment, memo, type ReactNode, useState } from 'react' +import { Fragment, memo, type ReactNode } from 'react' +import { openAgentTerminal } from '@/app/right-sidebar/terminal/terminals' import { StatusRow } from '@/components/chat/status-row' -import { TerminalOutput } from '@/components/chat/terminal-output' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { Tip } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' @@ -82,7 +81,6 @@ interface StatusItemRowProps { export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) { const { t } = useI18n() const s = t.statusStack - const [outputOpen, setOutputOpen] = useState(false) const failed = item.state === 'failed' const running = item.state === 'running' @@ -94,8 +92,10 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp : null const canOpen = item.type === 'subagent' && !!onOpen - const hasOutput = item.type === 'background' && !!item.output - const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined + + // Background rows link to their read-only terminal tab; subagents open their session. + const onActivate = + item.type === 'background' ? () => openAgentTerminal(item.id, item.title) : canOpen ? onOpen : undefined return ( @@ -146,9 +146,7 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp {s.exit(item.exitCode)} )} - {hasOutput && } - {hasOutput && outputOpen && } ) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx index 671c368281c..4e881ff85b6 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/instance.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/instance.tsx @@ -1,14 +1,22 @@ import '@xterm/xterm/css/xterm.css' +import { useStore } from '@nanostores/react' + import { Button } from '@/components/ui/button' import { KbdCombo } from '@/components/ui/kbd' import { Loader } from '@/components/ui/loader' import { useI18n } from '@/i18n' import { cn } from '@/lib/utils' +import { $backgroundOutputByProc } from '@/store/composer-status' import { reportTerminalShell } from './terminals' +import { useAgentTerminal } from './use-agent-terminal' import { useTerminalSession } from './use-terminal-session' +// Absolute-stacked so inactive tabs keep layout size (a display:none host goes +// 0×0 and renders garbled on re-show); visibility toggles which one is seen. +const INSTANCE_CLASS = 'absolute inset-0 flex flex-col bg-(--ui-editor-surface-background) px-2 pb-2 pt-0' + interface TerminalInstanceProps { id: string cwd: string @@ -31,15 +39,7 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat }: Term return (
@@ -75,3 +75,24 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat }: Term
) } + +interface AgentTerminalInstanceProps { + active: boolean + procId: string +} + +/** Read-only mirror of an agent background process — a write-only xterm fed by + * the process's output tail (no PTY, no input). */ +export function AgentTerminalInstance({ active, procId }: AgentTerminalInstanceProps) { + const output = useStore($backgroundOutputByProc)[procId] ?? '' + const { hostRef } = useAgentTerminal({ active, output }) + + return ( +
+
+
+ ) +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index e7a74c7b1d9..33c52cdceeb 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -150,7 +150,11 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T role="tab" type="button" > - + diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts index 38c9c7125ac..bc72ea6ba69 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts @@ -16,6 +16,10 @@ export interface TerminalEntry { * (the project root if opened in one, else the backend's default). Switching * sessions never moves or recreates a terminal. */ cwd: string + /** `user` = interactive PTY shell. `agent` = read-only mirror of an agent + * background process (`terminal(background=true)`), keyed by `procId`. */ + kind: 'user' | 'agent' + procId?: string } export const $terminals = atom([]) @@ -33,15 +37,57 @@ const newId = () => * tie to session/project state); pass an explicit cwd to override. Returns the id. */ export function createTerminal(cwd: string = $currentCwd.get()): string { const id = newId() - $terminals.set([...$terminals.get(), { id, title: 'Terminal', auto: true, cwd }]) + $terminals.set([...$terminals.get(), { id, title: 'Terminal', auto: true, cwd, kind: 'user' }]) $activeTerminalId.set(id) return id } -/** Guarantee at least one terminal exists (called when the pane opens). */ +// Procs we've already surfaced a tab for — so closing an agent tab doesn't +// resurrect it on the next poll while the process is still running. +const surfacedProcs = new Set() + +const findByProc = (procId: string) => $terminals.get().find(term => term.procId === procId) + +/** Auto-surface an agent background process as a read-only tab — once. Returns + * the tab id, or null if it was already surfaced and the user has since closed it. */ +export function ensureAgentTerminal(procId: string, title: string): string | null { + const existing = findByProc(procId) + + if (existing) { + return existing.id + } + + if (surfacedProcs.has(procId)) { + return null + } + + surfacedProcs.add(procId) + const id = newId() + $terminals.set([...$terminals.get(), { id, title: title || 'agent', auto: false, cwd: '', kind: 'agent', procId }]) + + return id +} + +/** Open + focus an agent process's tab (the status-stack link), recreating it if + * the user had closed it. Opens the pane. */ +export function openAgentTerminal(procId: string, title: string): void { + surfacedProcs.add(procId) + let id = findByProc(procId)?.id + + if (!id) { + id = newId() + $terminals.set([...$terminals.get(), { id, title: title || 'agent', auto: false, cwd: '', kind: 'agent', procId }]) + } + + $activeTerminalId.set(id) + setTerminalTakeover(true) +} + +/** Guarantee at least one user shell exists (called when the pane opens) — agent + * mirror tabs don't count, so opening the pane always yields a real shell. */ export function ensureTerminal(): void { - if (!$terminals.get().length) { + if (!$terminals.get().some(term => term.kind === 'user')) { createTerminal() } } diff --git a/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts new file mode 100644 index 00000000000..aab7c1428a0 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/use-agent-terminal.ts @@ -0,0 +1,142 @@ +import { FitAddon } from '@xterm/addon-fit' +import { WebglAddon } from '@xterm/addon-webgl' +import { Terminal } from '@xterm/xterm' +import { useEffect, useRef } from 'react' + +import { useTheme } from '@/themes/context' + +import { resolveSurfaceColor, terminalTheme } from './selection' + +// Read-only terminal driven by a string (an agent background process's output +// tail), not a PTY — no input, no shell. Shares the user terminal's look so the +// two read as one surface. +export function useAgentTerminal({ active, output }: { active: boolean; output: string }) { + const { renderedMode, theme, themeName } = useTheme() + const hostRef = useRef(null) + const termRef = useRef(null) + const webglRef = useRef(null) + const fitRef = useRef<(() => void) | null>(null) + const writtenRef = useRef('') + + const surfaceTheme = () => { + const ansi = renderedMode === 'dark' ? (theme.darkTerminal ?? theme.terminal) : theme.terminal + const surface = resolveSurfaceColor('#ffffff') + + return { ...terminalTheme(renderedMode, ansi), background: surface, cursorAccent: surface } + } + + useEffect(() => { + const host = hostRef.current + + if (!host) { + return + } + + const term = new Terminal({ + allowProposedApi: true, + convertEol: true, + cursorBlink: false, + disableStdin: true, + fontFamily: "'JetBrains Mono', 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace", + fontSize: 11, + lineHeight: 1.12, + minimumContrastRatio: 4.5, + scrollback: 5000, + theme: surfaceTheme() + }) + + const fit = new FitAddon() + term.loadAddon(fit) + term.open(host) + termRef.current = term + + fitRef.current = () => { + if (host.clientWidth > 0 && host.clientHeight > 0) { + try { + fit.fit() + } catch { + // Mid-transition layout — the next observer tick refits. + } + } + } + + try { + const webgl = new WebglAddon() + webgl.onContextLoss(() => { + webgl.dispose() + webglRef.current = null + }) + term.loadAddon(webgl) + webglRef.current = webgl + } catch { + // No WebGL — xterm falls back to the DOM renderer. + } + + fitRef.current() + const observer = new ResizeObserver(() => fitRef.current?.()) + observer.observe(host) + + return () => { + observer.disconnect() + term.dispose() + termRef.current = null + webglRef.current = null + writtenRef.current = '' + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Append the delta when the tail just grew; otherwise (the rolling window slid) + // reset and rewrite. Avoids reflowing the whole buffer on every poll. + useEffect(() => { + const term = termRef.current + + if (!term) { + return + } + + if (output.startsWith(writtenRef.current)) { + term.write(output.slice(writtenRef.current.length)) + } else { + term.reset() + term.write(output) + } + + writtenRef.current = output + }, [output]) + + useEffect(() => { + const term = termRef.current + + if (!term) { + return + } + + const raf = requestAnimationFrame(() => { + term.options.theme = surfaceTheme() + webglRef.current?.clearTextureAtlas() + }) + + return () => cancelAnimationFrame(raf) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renderedMode, themeName]) + + // A visibility:hidden xterm doesn't paint — refit + redraw on re-activation. + useEffect(() => { + if (!active) { + return + } + + const frame = requestAnimationFrame(() => { + const term = termRef.current + + fitRef.current?.() + webglRef.current?.clearTextureAtlas() + term?.refresh(0, term.rows - 1) + }) + + return () => cancelAnimationFrame(frame) + }, [active]) + + return { hostRef } +} diff --git a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx index acf651e5edd..48389e0b6c7 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/workspace.tsx @@ -1,14 +1,20 @@ import { useStore } from '@nanostores/react' import { useEffect } from 'react' +import { $backgroundStatusBySession, refreshBackgroundProcesses } from '@/store/composer-status' +import { $activeSessionId } from '@/store/session' + import { setActiveTerminalId } from './buffer' -import { TerminalInstance } from './instance' -import { $activeTerminalId, $terminals } from './terminals' +import { AgentTerminalInstance, TerminalInstance } from './instance' +import { $activeTerminalId, $terminals, ensureAgentTerminal } from './terminals' interface TerminalWorkspaceProps { onAddSelectionToChat: (text: string, label?: string) => void } +// Faster than the 5s status-stack poll so an open agent tab tails near-live. +const AGENT_POLL_MS = 1500 + /** The persistent-overlay layer: the stack of live xterm instances (only these * must stay in the fixed overlay, for the WebGL host). Mount/visibility is owned * by PersistentTerminal (latched so shells survive hiding); the tab rail and @@ -16,6 +22,8 @@ interface TerminalWorkspaceProps { export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspaceProps) { const terminals = useStore($terminals) const activeId = useStore($activeTerminalId) + const activeSession = useStore($activeSessionId) + const background = useStore($backgroundStatusBySession) // Mirror the tab selection into the agent reader (read_terminal reads it). useEffect(() => { @@ -27,17 +35,41 @@ export function TerminalWorkspace({ onAddSelectionToChat }: TerminalWorkspacePro } }, []) + // Surface the agent's background processes as read-only tabs (once each). + useEffect(() => { + for (const item of (activeSession && background[activeSession]) || []) { + ensureAgentTerminal(item.id, item.title) + } + }, [background, activeSession]) + + // While an agent tab exists, tail its process faster than the status stack. + const hasAgent = terminals.some(term => term.kind === 'agent') + + useEffect(() => { + if (!hasAgent || !activeSession) { + return + } + + const interval = setInterval(() => void refreshBackgroundProcesses(activeSession), AGENT_POLL_MS) + + return () => clearInterval(interval) + }, [hasAgent, activeSession]) + return ( <> - {terminals.map(term => ( - - ))} + {terminals.map(term => + term.kind === 'agent' ? ( + + ) : ( + + ) + )} ) } diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index 4d20b476b74..fe6f4262972 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -35,6 +35,22 @@ export interface ComposerStatusItem { // registry (`terminal(background=true)` spawns) via `process.list`. export const $backgroundStatusBySession = atom>({}) +// Flattened process-id → output tail, for the read-only agent terminal tabs that +// mirror background processes (keyed globally since a tab outlives its session view). +export const $backgroundOutputByProc = computed($backgroundStatusBySession, bySession => { + const out: Record = {} + + for (const list of Object.values(bySession)) { + for (const item of list) { + if (item.output) { + out[item.id] = item.output + } + } + } + + return out +}) + // Rows the user X-ed away. The registry keeps finished processes around for a // while, so without this every refresh would resurrect a dismissed row. const dismissedBySession = new Map>()