feat: 为 agent 暴露 8 个终端工具(terminal_create/list/send/read/wait_for/resize/signal/close),侧边栏自动同步 agent 终端 tab,v0.2.0

This commit is contained in:
HuanLinOTO
2026-08-10 19:08:58 +08:00
parent 61b18cbefe
commit 6f62d19905
15 changed files with 1704 additions and 31 deletions
+55 -2
View File
@@ -16,6 +16,7 @@ import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutlin
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
import {
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
reconcileAgentTerminals,
resizeSplit, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
@@ -137,6 +138,49 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
/**
* Agent terminals push: subscribe to the host's live list of agent-owned
* terminals for this session (created by the model through the
* `terminal_create` tool). The host pushes a JSON array on every
* create / close / exit; the sidebar reconciles the list into tabs
* (id `agent:<uuid>`, title from the agent). A disconnected socket
* retries with a short backoff so a refresh or transient drop reattaches
* the same shell without losing the agent's work.
*/
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
try {
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
if (!Array.isArray(list)) return
store.reduce(s => reconcileAgentTerminals(s, list))
} catch {
// Malformed push: ignore (the next push will reconcile).
}
}
socket.onclose = () => {
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => { socket?.close() }
}
connect()
return () => {
closed = true
window.clearTimeout(retry)
socket?.close()
}
}, [sessionId, store])
/**
* Subagent auto-activation: the moment the current conversation spawns its
* FIRST direct subagent (a 0 → N transition on the list feed) and the
@@ -212,12 +256,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
// Agent terminals (tabId `agent:<uuid>`) close through a different
// host route: the WS close frame is the primary path (sent by
// TerminalView on unmount), and the agent-pty.close HTTP route is the
// fallback when the WS is down.
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
store.reduce(s => closeTab(s, paneId, tabId))
if (sessionId !== undefined && tab?.type === 'terminal') {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
if (tab?.type === 'terminal') {
if (tabId.startsWith('agent:')) {
const uuid = tabId.slice('agent:'.length)
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
} else if (sessionId !== undefined) {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
}
},
activateTab: (paneId, tabId) => {
+40 -6
View File
@@ -7,6 +7,17 @@
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*
* Two attach modes share one upgrade endpoint:
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
* terminal closes when the user closes the tab); a bare socket drop
* leaves the pty alive (the agent owns the lifetime).
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
* frame schedules a 0-ms close; a bare socket drop gets the host's
* reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from 'xterm'
@@ -21,6 +32,19 @@ import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
const AGENT_TAB_PREFIX = 'agent:'
/** Whether a tab id refers to an agent-owned terminal. */
function isAgentTab(tabId: string): boolean {
return tabId.startsWith(AGENT_TAB_PREFIX)
}
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
function agentUuidOf(tabId: string): string {
return tabId.slice(AGENT_TAB_PREFIX.length)
}
/**
* Curated ANSI palettes for the terminal. The surface colors (background,
* foreground, cursor, selection) ride the theme tokens so the terminal
@@ -97,14 +121,21 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
let failures = 0
const wsUrl = (): string => {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
// Agent terminals attach by uuid (the host looks them up in the agent
// pty registry); UI-tab terminals attach by sessionId+tab (the host
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
if (isAgentTab(tabId)) {
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
} else {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
url.search = params.toString()
}
// Same construction the app's own downlink WebSockets use (new URL
// over location.origin + protocol swap): whatever the environment
// does to the app's websockets applies identically here.
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = params.toString()
return url.toString()
}
@@ -175,11 +206,14 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
// quota release). A bare unmount — conversation switch, re-render,
// pty release). A bare unmount — conversation switch, re-render,
// page unload — leaves the tab open, so the socket drop alone hands
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
// Agent terminals follow the same rule: a close frame kills the pty
// (the user closed the sidebar tab); a bare socket drop leaves it
// alive (the agent owns the lifetime).
if (!store.tabOpen(scope.sessionId, tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
+3
View File
@@ -119,6 +119,9 @@ export const api = {
* this explicit route). */
ptyClose: (scope: SessionScope, tab: string) =>
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}),
+49
View File
@@ -396,6 +396,55 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del
}
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
/**
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
* The host pushes the current list of agent terminals (created by the model
* through the `terminal_create` tool) over a dedicated WebSocket; this
* reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
* lose theirs. The agent owns the lifetime — the user closing a tab sends a
* WS close frame that kills the pty, which fires a change, which converges
* the view. Idempotent: a no-op when the lists already match.
* @param state - the current per-session sidebar state.
* @param agentTerminals - the live agent terminal snapshots from the host.
* @returns the next state (or the same reference if no change was needed).
*/
export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
const existingAgentTabs = existingTabs.filter(tab => tab.id.startsWith(AGENT_TAB_PREFIX))
const existingUuids = new Set(existingAgentTabs.map(tab => tab.id.slice(AGENT_TAB_PREFIX.length)))
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(tab.id.slice(AGENT_TAB_PREFIX.length)))
if (toAdd.length === 0 && toRemove.length === 0) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
let splits = state.splits
for (const tab of toRemove) {
const leaf = leafWithTab(splits, tab.id)
if (leaf !== undefined) {
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// active pane via openTab; the next reconcile is a no-op for them.
let next: SidebarState = { ...state, splits }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: `${AGENT_TAB_PREFIX}${terminal.uuid}`,
type: 'terminal',
title: terminal.title,
}
next = openTab(next, tab)
}
return next
}
// ── The per-session store ──────────────────────────────────────────────────
const STORAGE_PREFIX = 'dsh-sidebar:v1'