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>()