feat(desktop): mirror agent background terminals as read-only tabs

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.
This commit is contained in:
Brooklyn Nicholson 2026-06-28 19:26:21 -05:00
parent 6e12f8ce4a
commit ad831dd492
7 changed files with 291 additions and 32 deletions

View file

@ -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 (
<Fragment>
@ -146,9 +146,7 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
{s.exit(item.exitCode)}
</span>
)}
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
</StatusRow>
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
</Fragment>
)
})

View file

@ -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 (
<div
className={cn(
// Stack every terminal absolutely and toggle visibility (NOT display) so
// inactive tabs keep their layout size and track pane resizes — a
// display:none host goes 0×0, skips fit, and renders garbled when shown
// again at a changed size. No top padding so the prompt hugs the
// titlebar-clearance line (the rest of the gap is required clearance).
'absolute inset-0 flex flex-col bg-(--ui-editor-surface-background) px-2 pb-2 pt-0',
active ? 'visible' : 'invisible pointer-events-none'
)}
className={cn(INSTANCE_CLASS, active ? 'visible' : 'invisible pointer-events-none')}
// Focus-scope marker so isFocusWithin('[data-terminal]') can route ⌘W here.
data-terminal=""
>
@ -75,3 +75,24 @@ export function TerminalInstance({ id, active, cwd, onAddSelectionToChat }: Term
</div>
)
}
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 (
<div className={cn(INSTANCE_CLASS, active ? 'visible' : 'invisible pointer-events-none')}>
<div
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>
)
}

View file

@ -150,7 +150,11 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
role="tab"
type="button"
>
<Codicon name="terminal" size="0.875rem" />
<Codicon
className={cn(term.kind === 'agent' && !active && 'text-primary')}
name={term.kind === 'agent' ? 'sparkle' : 'terminal'}
size="0.875rem"
/>
</button>
</Tip>
</li>

View file

@ -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<readonly TerminalEntry[]>([])
@ -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<string>()
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()
}
}

View file

@ -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<HTMLDivElement | null>(null)
const termRef = useRef<Terminal | null>(null)
const webglRef = useRef<WebglAddon | null>(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 }
}

View file

@ -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 => (
<TerminalInstance
active={term.id === activeId}
cwd={term.cwd}
id={term.id}
key={term.id}
onAddSelectionToChat={onAddSelectionToChat}
/>
))}
{terminals.map(term =>
term.kind === 'agent' ? (
<AgentTerminalInstance active={term.id === activeId} key={term.id} procId={term.procId!} />
) : (
<TerminalInstance
active={term.id === activeId}
cwd={term.cwd}
id={term.id}
key={term.id}
onAddSelectionToChat={onAddSelectionToChat}
/>
)
)}
</>
)
}

View file

@ -35,6 +35,22 @@ export interface ComposerStatusItem {
// registry (`terminal(background=true)` spawns) via `process.list`.
export const $backgroundStatusBySession = atom<Record<string, ComposerStatusItem[]>>({})
// 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<string, string> = {}
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<string, Set<string>>()