mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
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:
parent
6e12f8ce4a
commit
ad831dd492
7 changed files with 291 additions and 32 deletions
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue