diff --git a/docs/features/background-agent-selector.md b/docs/features/background-agent-selector.md new file mode 100644 index 0000000000..3acebb8929 --- /dev/null +++ b/docs/features/background-agent-selector.md @@ -0,0 +1,225 @@ +# Background Agent Selector — 底部统一后台 Agent 切换器 + +> Feature Flag: 无(直接启用) +> 实现状态:完整可用 +> 依赖:`viewingAgentTaskId` / `enterTeammateView` / `exitTeammateView` 已有机制 + +## 一、功能概述 + +Background Agent Selector 是渲染在 PromptInput 下方的常驻状态条,列出当前所有 **backgrounded 的 local_agent 任务**(包括 `/fork` 派生的 fork agent 和 Task/AgentTool 调用 `run_in_background: true` 派生的子 agent)。用户可以用 ↑/↓ 方向键在 `main` 和各 agent 之间切换焦点,按 Enter 把 REPL 主视图替换为所选 agent 的实时 transcript,再按 Enter 选中 `main` 即可回到主对话。 + +整个机制完全复用官方已有的 teammate transcript 查看基础设施,不引入新的视图层 / 数据流,仅新增一条 footer pill 类型。 + +### 核心特性 + +- **统一入口**:`/fork`、Task 派生的 subagent、所有 `run_in_background: true` 的 agent 都在同一栏显示 +- **就地切换**:prompt 为空时按 ↓ 溢出进入底部 selector,↑↓ 选中某行,Enter 即切主视图 +- **实时状态**:每行显示 agent 类型 + 描述 + 运行时长 + 已消耗 token;running 时圆点为绿色 +- **Keep-alive 视图**:agent 完成后在 `evictAfter` grace 窗口内保留一段时间,用户可回看 +- **零界面侵入**:tasks 数为 0 时 selector 完全不渲染,不占屏幕高度 +- **与旧 Dialog 共存**:Shift+↓ 打开的 `BackgroundTasksDialog` 原有行为保留,selector 只作为展示 + 快捷切换 + +## 二、用户交互 + +### 触发方式 + +有任何 background agent 时,selector 自动出现在 `bypass permissions on` 行下方: + +``` + claude-code | Opus 4.7 (1M context) | ctx:4% + ▶▶ bypass permissions on (shift+tab to cycle) + + ○ main ↑/↓ to select · Enter to view + ● Explore Research src/hooks 23s · ↓ 10.9k tokens + ○ Explore Research src/components 22s · ↓ 9.5k tokens + ○ Explore Research src/utils 21s · ↓ 13.6k tokens +``` + +### 键盘路由 + +| 位置 / 状态 | 按键 | 行为 | +|---|---|---| +| PromptInput 非空 | ↑↓ | 光标移动 / 翻历史(不变) | +| PromptInput 空 + 历史底部 | ↓ | 焦点下放到 selector,高亮到 `● main` | +| Selector 聚焦(`footerSelection === 'bg_agent'`) | ↓ | 高亮下移,-1 → 0 → ... → N-1 | +| Selector 聚焦 | ↑ | 高亮上移;在 `main` 再 ↑ → 焦点回 PromptInput | +| Selector 聚焦 | Enter | `-1` → `exitTeammateView`;`>=0` → `enterTeammateView(agentId)`。焦点保留在 pill | +| Selector 聚焦 | Esc | `footer:clearSelection`,焦点回 PromptInput | + +### 视觉规则 + +- `● main` / `● `:当前被**查看**(viewingAgentTaskId 指向)或被**光标聚焦**(pill focused 时以光标为准)的一行 +- running 状态的 agent:圆点渲染为 `success` 色(绿色),与 `BackgroundTasksDialog` 状态语义对齐 +- 右上角 hint 随状态变化: + - pill 聚焦:`↑/↓ to select · Enter to view` + - 已选中 running agent:`shift+↓ to manage · x to stop` + - 已选中 terminal agent:`shift+↓ to manage · x to clear` + - 未选中任何 agent:`shift+↓ to manage background agents` + +## 三、实现架构 + +### 3.1 数据层:`useBackgroundAgentTasks` + +文件:`src/hooks/useBackgroundAgentTasks.ts` + +封装对 `useAppState(s => s.tasks)` 的过滤: + +```ts +export function useBackgroundAgentTasks(): LocalAgentTaskState[] { + const tasks = useAppState(s => s.tasks) + return useMemo(() => { + const now = Date.now() + return Object.values(tasks) + .filter(isLocalAgentTask) + .filter(t => t.agentType !== 'main-session') + .filter(t => t.isBackgrounded !== false) + .filter(t => t.evictAfter === undefined || t.evictAfter > now) + .sort((a, b) => a.startTime - b.startTime) + }, [tasks]) +} +``` + +`/fork` 和 `AgentTool` 的 `run_in_background: true` 底层都走 `registerAsyncAgent → runAsyncAgentLifecycle`,最终写入同一个 `appState.tasks` Map;此 hook 是唯一数据源,Selector 和 PromptInput 的 `bgAgentList` 都消费它。 + +### 3.2 状态层:新增两个字段 + +文件:`src/state/AppStateStore.ts` + +```ts +export type FooterItem = + | 'tasks' | 'tmux' | 'bagel' | 'teams' | 'bridge' | 'companion' + | 'bg_agent' // ← 新增 + +export type AppState = DeepImmutable<{ + // ... + selectedBgAgentIndex: number // -1 = main, 0..N-1 = 选中的 agent +}> +``` + +- `'bg_agent'` 作为 `FooterItem` 加入 footer pill 体系,享受既有的 `footer:up` / `footer:down` / `footer:openSelected` keybinding 路由 +- `selectedBgAgentIndex` 记录 selector 的光标位置,与 `viewingAgentTaskId`("正在看什么")独立;它不可从 `viewingAgentTaskId` 派生——Enter 后光标留在 pill 继续导航,查看目标才变 + +### 3.3 键盘路由:PromptInput footer pill 分支 + +文件:`src/components/PromptInput/PromptInput.tsx` + +1. **`bg_agent` 进入 footerItems[0]**:保证 prompt ↓ 溢出时(`handleHistoryDown` → `selectFooterItem(footerItems[0])`)直接进入 selector,而不是 `tasks` 等其他 pill +2. **`footer:up` 分支**:`bgAgentSelected` 时 `selectedBgAgentIndex > -1` 则递减;在 -1 → `selectFooterItem(null)` 退出 pill +3. **`footer:down` 分支**:`selectedBgAgentIndex < bgAgentList.length - 1` 则递增,到底 clamp +4. **`footer:openSelected` 分支**:index === -1 → `exitTeammateView`;否则 `enterTeammateView(bgAgentList[i].agentId)`。**不清理 pill 焦点**,光标留在 selector 上继续导航 +5. **`selectFooterItem('bg_agent')`**:入 pill 时重置 `selectedBgAgentIndex = -1`(光标落到 `main`) + +### 3.4 渲染层:`BackgroundAgentSelector` + +文件:`src/components/tasks/BackgroundAgentSelector.tsx` + +纯展示组件,不订阅键盘: + +```tsx +const tasks = useBackgroundAgentTasks() +const viewingId = useAppState(s => s.viewingAgentTaskId) +const footerSelection = useAppState(s => s.footerSelection) +const selectedBgIndex = useAppState(s => s.selectedBgAgentIndex) + +if (tasks.length === 0) return null + +const pillFocused = footerSelection === 'bg_agent' +const highlightedId = pillFocused + ? (selectedBgIndex === -1 ? null : tasks[selectedBgIndex]?.agentId ?? null) + : (viewingId ?? null) +``` + +**高亮派生规则**:pill 聚焦 → 跟 `selectedBgAgentIndex`;未聚焦 → 镜像 `viewingAgentTaskId`。这样当用户通过 Shift+↓ Dialog 或 `enterTeammateView` 其它途径切换视图时,selector 也会正确反映。 + +### 3.5 主视图切换:复用 `viewingAgentTaskId` + +REPL.tsx 主体仍复用原有查看逻辑: + +```ts +const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined +const viewedAgentTask = ... (isLocalAgentTask(viewedTask) ? viewedTask : undefined) +const displayedMessages = viewedAgentTask ? displayedAgentMessages : messages +``` + +当 `enterTeammateView(agentId)` 把 `viewingAgentTaskId` 设成某个 local_agent 的 id: + +- `viewedAgentTask` 解析成该 agent +- `displayedMessages` 切换到 agent 的 messages +- 消息列表、spinner、unseen divider 等一整套组件自动用 agent transcript 重渲染 +- 主对话流被"暂停"(并非销毁,回到 `main` 时仍在原处) + +`enterTeammateView` 同步负责:设 `retain: true` 阻止 eviction、清 `evictAfter`、触发 disk bootstrap 从 `agent-.jsonl` 加载完整 transcript 到 `task.messages`。 + +#### Fork agent prompt 归一化 + +`/fork` agent 的 transcript 和普通 subagent 不同:它继承 main agent 的上下文,真实初始消息形态是: + +```text +...parent messages +assistant([...tool_use]) +user([tool_result..., text("...Your directive: ")]) +...fork live messages +``` + +这里的 prompt 文本混在 `[tool_result..., text]` 多 block user message 里。消息渲染管线会优先把这条 user message 当作 tool-result plumbing 来处理,导致 `` 里的用户 prompt 不稳定可见。为保证切换到 fork agent 时总能看到用户发起的 fork prompt,REPL.tsx 对 fork 视图做一次展示层归一化: + +1. 仅当 `viewedAgentTask.agentType === 'fork'` 时启用,不影响普通 Explore / Task subagent。 +2. 从原始 messages 中识别包含 `` 的 carrier message。 +3. 剥离 carrier message 里的 boilerplate text block,但保留 `tool_result` blocks,避免破坏父 assistant `tool_use` 的承接关系。 +4. 强制插入一条独立 `createUserMessage({ content: viewedAgentTask.prompt })` 作为可见用户 prompt。 +5. 插入位置优先为 boilerplate carrier 后;如果 sidechain bootstrap 还没读到 carrier,则插到最后一条 inherited `assistant tool_use` 后面,确保 prompt 接在 main 上下文之后,而不是跑到视图顶部。 + +这个归一化只影响 UI 展示用的 `displayedAgentMessages`,不回写 `task.messages`,也不改变发送给模型的 fork transcript。 + +### 3.6 生命周期 + +完全复用官方既有机制: + +- **运行中**:`isBackgroundTask()` 谓词为真,selector 列出 +- **完成 / 失败 / 中止**:`completeAgentTask` / `failAgentTask` / `killAsyncAgent` 设 `status` 为 terminal +- **回访后退出**:`exitTeammateView` 调 `release(task)`——清 `retain`、清 `messages`、terminal 状态下设 `evictAfter = now + PANEL_GRACE_MS (30s)` +- **evictAfter 过期**:`useBackgroundAgentTasks` 过滤时自然剔除,selector 行消失 +- **手动清除**:`stopOrDismissAgent(taskId)` 设 `evictAfter = 0`,立即消失 + +## 四、设计决策 + +1. **数据源单一**:`useBackgroundAgentTasks` 是唯一过滤点,PromptInput 也复用,避免过滤条件散落 +2. **pill 聚焦保留**:Enter 切视图后不松焦,让 ↑↓ 连续导航,贴近官方体验 +3. **`bg_agent` 放 footerItems[0]**:确保 ↓ 溢出直接进入 selector 而非其它 pill +4. **selector 不订阅键盘**:所有按键路由集中在 PromptInput 的 `footer:*` 分支,避免 selector 组件和 PromptInput 双重 `useInput` 的冲突 +5. **`selectedBgAgentIndex` 存 AppState 而非局部 state**:selector 和 PromptInput 分别在两棵不同子树,需要全局字段协调;该值不能从 `viewingAgentTaskId` 派生 +6. **与 `BackgroundTasksDialog` 共存**:Shift+↓ 行为完全不变,selector 是补充快捷入口;Dialog 仍管 shell / workflow / monitor_mcp 等 selector 不显示的 task 类型 +7. **fork prompt 展示层兜底**:fork prompt 不依赖 boilerplate 自身渲染,统一在 `displayedAgentMessages` 中合成独立用户消息;普通 subagent 不走该分支,避免 prompt 重复 + +## 五、关键 API 复用 + +| 官方已有能力 | selector 如何使用 | +|---|---| +| `AppState.tasks` | 单一数据源,无需 file watcher / output JSONL 订阅 | +| `registerAsyncAgent` | `/fork` 和 AgentTool 共用,selector 不区分来源 | +| `enterTeammateView(id)` | Enter 时调用,负责 retain + disk bootstrap | +| `exitTeammateView` | Enter 选中 `main` 时调用 | +| `release(task)` + `PANEL_GRACE_MS` | 30s keep-alive,selector 自动生效 | +| `useElapsedTime` | 每行时长显示,非 running 自动停 interval | +| `formatTokens` (`utils/format.ts`) | token 数 1k 缩写 | +| `footer:up` / `footer:down` / `footer:openSelected` keybinding | 键盘路由复用 Footer context | + +## 六、文件索引 + +| 文件 | 职责 | +|------|------| +| `src/hooks/useBackgroundAgentTasks.ts` | 数据过滤 hook(backgrounded local_agent + evictAfter 过滤 + startTime 排序) | +| `src/components/tasks/BackgroundAgentSelector.tsx` | 底部 selector UI,纯展示 | +| `src/components/PromptInput/PromptInput.tsx` | 新增 `'bg_agent'` footer pill + 对应的 `footer:up/down/openSelected` 分支 | +| `src/state/AppStateStore.ts` | `FooterItem` 加 `'bg_agent'`;新增 `selectedBgAgentIndex` 字段 | +| `src/main.tsx` | `getDefaultAppState` 同步初始化 `selectedBgAgentIndex: -1` | +| `src/screens/REPL.tsx` | 在 PromptInput + SessionBackgroundHint 之后挂载 ``;切换 agent 主视图;对 fork transcript 做 prompt 归一化 | +| `src/components/messages/AssistantToolUseMessage.tsx` | 新增 `defaultCollapsed?: boolean` prop,为后续详情视图默认折叠工具块预留 | +| `src/components/messages/UserTextMessage.tsx` | 识别 ``,交给 fork 专用 renderer 处理 | +| `src/components/messages/UserForkBoilerplateMessage.tsx` | 将 fork boilerplate text 折叠为纯用户 prompt;作为 transcript 中原位渲染的兼容路径 | + +## 七、已知限制 + +- `Date.now()` 在 `useBackgroundAgentTasks` 的 useMemo 里冻结于 `[tasks]` 触发时:若长时间没有新 task 变更事件,某个 terminal agent 的 grace 期过期后不会立即从 selector 消失,要等下一次 tasks 变化才刷新。在典型使用(主对话一直在产生消息)下感知不到,暂不额外加 interval。 +- Selector 当前不处理 Shell Task / Workflow / Monitor MCP 等类型——这些仍走 `BackgroundTasksDialog`(Shift+↓)管理。 +- `AssistantToolUseMessage` 的 `defaultCollapsed` prop 目前无调用方传值,保留作为后续"agent 详情视图内工具块默认折叠"扩展点。 diff --git a/src/commands/fork/fork.tsx b/src/commands/fork/fork.tsx index cd2f8d7014..2fa7f7ef30 100644 --- a/src/commands/fork/fork.tsx +++ b/src/commands/fork/fork.tsx @@ -43,8 +43,12 @@ export async function call( // Omitting subagent_type triggers implicit fork. const input = { prompt: directive, + fork: true, // 触发 AgentTool 的 fork 路径:继承父会话上下文 + system prompt + 模型 run_in_background: true, // fork always runs async - description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`, + // description 只显示在底部 selector / BackgroundTasksDialog,保持简短标签 + // 即可;用户输入的 prompt 会作为第一条用户消息呈现在主视图里,这里不要 + // 重复显示。 + description: 'forked from main', }; // Call AgentTool with proper parameters: diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 58d04c9100..5291cde222 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -26,6 +26,7 @@ import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js import { formatImageRef, formatPastedTextRef, getPastedTextRefNumLines, parseReferences } from '../../history.js'; import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; import { type HistoryMode, useArrowKeyHistory } from '../../hooks/useArrowKeyHistory.js'; +import { useBackgroundAgentTasks } from '../../hooks/useBackgroundAgentTasks.js'; import { useDoublePress } from '../../hooks/useDoublePress.js'; import { useHistorySearch } from '../../hooks/useHistorySearch.js'; import type { IDESelection } from '../../hooks/useIdeSelection.js'; @@ -415,6 +416,16 @@ function PromptInput({ // First ↓ selects the pill, second ↓ moves to row 0. Prevents double-select // of pill + row when both bg tasks (pill) and forked agents (rows) are visible. const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); + const selectedBgAgentIndex = useAppState(s => s.selectedBgAgentIndex); + const setSelectedBgAgentIndex = useCallback( + (v: number | ((prev: number) => number)) => + setAppState(prev => { + const next = typeof v === 'function' ? v(prev.selectedBgAgentIndex) : v; + if (next === prev.selectedBgAgentIndex) return prev; + return { ...prev, selectedBgAgentIndex: next }; + }), + [setAppState], + ); const setCoordinatorTaskIndex = useCallback( (v: number | ((prev: number) => number)) => setAppState(prev => { @@ -501,10 +512,13 @@ function PromptInput({ (runningTaskCount > 0 || (process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0)) && !shouldHideTasksFooter(tasks, showSpinnerTree); const teamsFooterVisible = cachedTeams.length > 0; + const bgAgentList = useBackgroundAgentTasks(); + const bgAgentFooterVisible = bgAgentList.length > 0; const footerItems = useMemo( () => [ + bgAgentFooterVisible && 'bg_agent', tasksFooterVisible && 'tasks', tmuxFooterVisible && 'tmux', bagelFooterVisible && 'bagel', @@ -513,6 +527,7 @@ function PromptInput({ companionFooterVisible && 'companion', ].filter(Boolean) as FooterItem[], [ + bgAgentFooterVisible, tasksFooterVisible, tmuxFooterVisible, bagelFooterVisible, @@ -540,6 +555,7 @@ function PromptInput({ const _bagelSelected = footerItemSelected === 'bagel'; const teamsSelected = footerItemSelected === 'teams'; const bridgeSelected = footerItemSelected === 'bridge'; + const bgAgentSelected = footerItemSelected === 'bg_agent'; function selectFooterItem(item: FooterItem | null): void { setAppState(prev => (prev.footerSelection === item ? prev : { ...prev, footerSelection: item })); @@ -547,6 +563,9 @@ function PromptInput({ setTeammateFooterIndex(0); setCoordinatorTaskIndex(minCoordinatorIndex); } + if (item === 'bg_agent') { + setSelectedBgAgentIndex(-1); + } } // delta: +1 = down/right, -1 = up/left. Returns true if nav happened @@ -1808,6 +1827,15 @@ function PromptInput({ useKeybindings( { 'footer:up': () => { + // ↑ in bg_agent pill: move selection up (-1 = main). At -1, leave pill. + if (bgAgentSelected) { + if (selectedBgAgentIndex > -1) { + setSelectedBgAgentIndex(prev => prev - 1); + } else { + selectFooterItem(null); + } + return; + } // ↑ scrolls within the coordinator task list before leaving the pill if ( tasksSelected && @@ -1821,6 +1849,13 @@ function PromptInput({ navigateFooter(-1, true); }, 'footer:down': () => { + // ↓ in bg_agent pill: move selection down through agents. Clamp at last. + if (bgAgentSelected) { + if (selectedBgAgentIndex < bgAgentList.length - 1) { + setSelectedBgAgentIndex(prev => prev + 1); + } + return; + } // ↓ scrolls within the coordinator task list, never leaves the pill if (tasksSelected && process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0) { if (coordinatorTaskIndex < coordinatorTaskCount - 1) { @@ -1906,6 +1941,15 @@ function PromptInput({ setShowBridgeDialog(true); selectFooterItem(null); break; + case 'bg_agent': + if (selectedBgAgentIndex === -1) { + exitTeammateView(setAppState); + } else { + const picked = bgAgentList[selectedBgAgentIndex]; + if (picked) enterTeammateView(picked.agentId, setAppState); + } + // Keep the pill focused so ↑/↓ continue to work after Enter. + break; } }, 'footer:clearSelection': () => { diff --git a/src/components/messages/AssistantToolUseMessage.tsx b/src/components/messages/AssistantToolUseMessage.tsx index f9e86a4dab..ccf55e53e1 100644 --- a/src/components/messages/AssistantToolUseMessage.tsx +++ b/src/components/messages/AssistantToolUseMessage.tsx @@ -30,6 +30,7 @@ type Props = { inProgressToolCallCount?: number; lookups: ReturnType; isTranscriptMode?: boolean; + defaultCollapsed?: boolean; }; export function AssistantToolUseMessage({ @@ -45,6 +46,7 @@ export function AssistantToolUseMessage({ inProgressToolCallCount, lookups, isTranscriptMode, + defaultCollapsed, }: Props): React.ReactNode { const terminalSize = useTerminalSize(); const [theme] = useTheme(); @@ -167,6 +169,7 @@ export function AssistantToolUseMessage({ {!isResolved && !isQueued && + !defaultCollapsed && (isClassifierChecking ? ( diff --git a/src/components/messages/UserForkBoilerplateMessage.tsx b/src/components/messages/UserForkBoilerplateMessage.tsx index 3dacf1c77a..3a70ae25ca 100644 --- a/src/components/messages/UserForkBoilerplateMessage.tsx +++ b/src/components/messages/UserForkBoilerplateMessage.tsx @@ -3,28 +3,31 @@ */ import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; import * as React from 'react'; -import { Box, Text } from '@anthropic/ink'; +import { FORK_BOILERPLATE_TAG, FORK_DIRECTIVE_PREFIX } from '../../constants/xml.js'; import { extractTag } from '../../utils/messages.js'; +import { UserPromptMessage } from './UserPromptMessage.js'; type Props = { addMargin: boolean; param: TextBlockParam; + isTranscriptMode?: boolean; + timestamp?: string; }; -export function UserForkBoilerplateMessage({ param, addMargin }: Props): React.ReactNode { - const text = param.text; - const extracted = extractTag(text, 'fork-boilerplate'); - if (!extracted) { - return null; - } - - const firstLine = extracted.trim().split('\n')[0] ?? ''; - const preview = firstLine.length > 80 ? firstLine.slice(0, 77) + '...' : firstLine; +export function UserForkBoilerplateMessage({ param, addMargin, isTranscriptMode, timestamp }: Props): React.ReactNode { + if (!extractTag(param.text, FORK_BOILERPLATE_TAG)) return null; + const closeTag = ``; + const afterTag = param.text.slice(param.text.indexOf(closeTag) + closeTag.length).trimStart(); + const userPrompt = afterTag.startsWith(FORK_DIRECTIVE_PREFIX) + ? afterTag.slice(FORK_DIRECTIVE_PREFIX.length) + : afterTag; return ( - - [fork] - {preview} - + ); } diff --git a/src/components/messages/UserTextMessage.tsx b/src/components/messages/UserTextMessage.tsx index e8798884cd..4abf73436a 100644 --- a/src/components/messages/UserTextMessage.tsx +++ b/src/components/messages/UserTextMessage.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { NO_CONTENT_MESSAGE } from '../../constants/messages.js'; import { COMMAND_MESSAGE_TAG, + FORK_BOILERPLATE_TAG, LOCAL_COMMAND_CAVEAT_TAG, TASK_NOTIFICATION_TAG, TEAMMATE_MESSAGE_TAG, @@ -124,16 +125,21 @@ export function UserTextMessage({ } // Fork child's first message: collapse the rules/format boilerplate, show - // only the directive. FORK_BOILERPLATE_TAG is inlined so the import doesn't - // ship in external builds where feature('FORK_SUBAGENT') is false. - if (feature('FORK_SUBAGENT')) { - if (param.text.includes('')) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { UserForkBoilerplateMessage } = - require('./UserForkBoilerplateMessage.js') as typeof import('./UserForkBoilerplateMessage.js'); - /* eslint-enable @typescript-eslint/no-require-imports */ - return ; - } + // only the user prompt. Independent of FORK_SUBAGENT flag — the fork agent + // transcript always needs to render the prompt as a normal user bubble. + if (param.text.includes(`<${FORK_BOILERPLATE_TAG}>`)) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { UserForkBoilerplateMessage } = + require('./UserForkBoilerplateMessage.js') as typeof import('./UserForkBoilerplateMessage.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + return ( + + ); } // Cross-session UDS message (from another Claude session's SendMessage). diff --git a/src/components/tasks/BackgroundAgentSelector.tsx b/src/components/tasks/BackgroundAgentSelector.tsx new file mode 100644 index 0000000000..be22fa4e4f --- /dev/null +++ b/src/components/tasks/BackgroundAgentSelector.tsx @@ -0,0 +1,63 @@ +import { Box, Text } from '@anthropic/ink'; +import { useBackgroundAgentTasks } from '../../hooks/useBackgroundAgentTasks.js'; +import { useElapsedTime } from '../../hooks/useElapsedTime.js'; +import { useAppState } from '../../state/AppState.js'; +import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; +import { formatTokens } from '../../utils/format.js'; + +function AgentRow({ task, selected }: { task: LocalAgentTaskState; selected: boolean }) { + const elapsed = useElapsedTime(task.startTime, task.status === 'running'); + const tokens = task.progress?.tokenCount ?? 0; + const isRunning = task.status === 'running'; + return ( + + + {selected ? '● ' : '○ '} + + {task.agentType} {task.description} + + + + + {elapsed} · ↓ {formatTokens(tokens)} tokens + + + + ); +} + +function getHint(pillFocused: boolean, viewedTask: LocalAgentTaskState | null): string { + if (pillFocused) return '↑/↓ to select · Enter to view'; + if (!viewedTask) return 'shift+↓ to manage background agents'; + return viewedTask.status === 'running' ? 'shift+↓ to manage · x to stop' : 'shift+↓ to manage · x to clear'; +} + +export function BackgroundAgentSelector(): React.ReactNode { + const tasks = useBackgroundAgentTasks(); + const viewingId = useAppState(s => s.viewingAgentTaskId); + const footerSelection = useAppState(s => s.footerSelection); + const selectedBgIndex = useAppState(s => s.selectedBgAgentIndex); + + if (tasks.length === 0) return null; + + const pillFocused = footerSelection === 'bg_agent'; + const highlightedId = pillFocused + ? selectedBgIndex === -1 + ? null + : (tasks[selectedBgIndex]?.agentId ?? null) + : (viewingId ?? null); + const mainHighlighted = pillFocused ? selectedBgIndex === -1 : viewingId === undefined; + const viewedTask = viewingId ? (tasks.find(t => t.agentId === viewingId) ?? null) : null; + + return ( + + + {mainHighlighted ? '● ' : '○ '}main + {getHint(pillFocused, viewedTask)} + + {tasks.map(task => ( + + ))} + + ); +} diff --git a/src/hooks/useBackgroundAgentTasks.ts b/src/hooks/useBackgroundAgentTasks.ts new file mode 100644 index 0000000000..5372d9afc1 --- /dev/null +++ b/src/hooks/useBackgroundAgentTasks.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react' +import { useAppState } from '../state/AppState.js' +import { + isLocalAgentTask, + type LocalAgentTaskState, +} from '../tasks/LocalAgentTask/LocalAgentTask.js' + +export function useBackgroundAgentTasks(): LocalAgentTaskState[] { + const tasks = useAppState(s => s.tasks) + return useMemo(() => { + const now = Date.now() + return Object.values(tasks) + .filter(isLocalAgentTask) + .filter(t => t.agentType !== 'main-session') + .filter(t => t.isBackgrounded !== false) + .filter(t => t.evictAfter === undefined || t.evictAfter > now) + .sort((a, b) => a.startTime - b.startTime) + }, [tasks]) +} diff --git a/src/main.tsx b/src/main.tsx index 169a555f14..84b581faad 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3485,6 +3485,7 @@ async function run(): Promise { : 'none', showTeammateMessagePreview: isAgentSwarmsEnabled() ? false : undefined, selectedIPAgentIndex: -1, + selectedBgAgentIndex: -1, coordinatorTaskIndex: -1, viewSelectionMode: 'none', footerSelection: null, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 750da47ca5..83eb7f02c4 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -244,7 +244,14 @@ import { formatCommandInputTags, } from '../utils/messages.js'; import { generateSessionTitle } from '../utils/sessionTitle.js'; -import { BASH_INPUT_TAG, COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG, LOCAL_COMMAND_STDOUT_TAG } from '../constants/xml.js'; +import { + BASH_INPUT_TAG, + COMMAND_MESSAGE_TAG, + COMMAND_NAME_TAG, + FORK_BOILERPLATE_TAG, + LOCAL_COMMAND_STDOUT_TAG, +} from '../constants/xml.js'; +import { FORK_SUBAGENT_TYPE } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js'; import { escapeXml } from '../utils/xml.js'; import type { ThinkingConfig } from '../utils/thinking.js'; import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; @@ -336,6 +343,7 @@ import { import { isBgSession, updateSessionName, updateSessionActivity } from '../utils/concurrentSessions.js'; import { isInProcessTeammateTask, type InProcessTeammateTaskState } from '../tasks/InProcessTeammateTask/types.js'; import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { BackgroundAgentSelector } from '../components/tasks/BackgroundAgentSelector.js'; import { useInboxPoller } from '../hooks/useInboxPoller.js'; // Dead code elimination: conditional import for loop mode /* eslint-disable @typescript-eslint/no-require-imports */ @@ -800,6 +808,21 @@ export type Props = { export type Screen = 'prompt' | 'transcript'; +// Boilerplate carrier lives in a mixed user message ([tool_result..., text]) +// that AgentTool/forkSubagent.buildForkedMessages emits as the fork child's +// first user turn. The text block wraps ... + the +// user prompt; tool_result siblings keep the parent's tool calls closed. +const FORK_BOILERPLATE_OPEN_TAG = `<${FORK_BOILERPLATE_TAG}>`; + +function isForkBoilerplateTextBlock(block: { type: string; text?: string }): boolean { + return block.type === 'text' && typeof block.text === 'string' && block.text.includes(FORK_BOILERPLATE_OPEN_TAG); +} + +function isForkBoilerplateMessage(message: MessageType): boolean { + if (message.type !== 'user' || !Array.isArray(message.message?.content)) return false; + return message.message.content.some(isForkBoilerplateTextBlock); +} + export function REPL({ commands: initialCommands, debug, @@ -5548,8 +5571,72 @@ export function REPL({ const usesSyncMessages = showStreamingText || !isLoading; // When viewing an agent, never fall through to leader — empty until // bootstrap/stream fills. Closes the see-leader-type-agent footgun. + const rawAgentMessages = viewedAgentTask?.messages; + // Fork sidechain encodes the user prompt inside a mixed user message alongside + // tool_result blocks; surface the prompt as a standalone bubble and strip the + // boilerplate text from its original carrier while preserving tool_results. + const displayedAgentMessages = useMemo(() => { + if (!viewedAgentTask) return undefined; + const agentMessages = rawAgentMessages ?? []; + if ( + !isLocalAgentTask(viewedAgentTask) || + viewedAgentTask.agentType !== FORK_SUBAGENT_TYPE || + !viewedAgentTask.prompt + ) { + return agentMessages; + } + // Single pass: locate boilerplate carrier, check whether the prompt text is + // already present elsewhere, and find the fallback insertion point (after + // the last parent assistant tool_use). + const trimmedPrompt = viewedAgentTask.prompt.trim(); + let boilerplateIndex = -1; + let lastAssistantToolUseIndex = -1; + let promptAlreadyRendered = false; + for (let i = 0; i < agentMessages.length; i++) { + const m = agentMessages[i]!; + if (m.type === 'user' && Array.isArray(m.message?.content)) { + const hasBoilerplate = m.message.content.some(isForkBoilerplateTextBlock); + if (hasBoilerplate) { + boilerplateIndex = i; + } else if (!promptAlreadyRendered) { + const firstText = m.message.content.find(b => b.type === 'text' && typeof b.text === 'string') as + | { type: 'text'; text: string } + | undefined; + if (firstText && firstText.text.trim() === trimmedPrompt) promptAlreadyRendered = true; + } + continue; + } + if (m.type === 'assistant' && Array.isArray(m.message?.content)) { + if (m.message.content.some(b => b.type === 'tool_use')) lastAssistantToolUseIndex = i; + } + } + + const stripped = + boilerplateIndex === -1 + ? agentMessages + : agentMessages.map((m, i) => { + if (i !== boilerplateIndex) return m; + if (!Array.isArray(m.message?.content)) return m; + return { + ...m, + message: { + ...m.message, + content: m.message.content.filter(b => !isForkBoilerplateTextBlock(b)), + }, + }; + }); + + if (promptAlreadyRendered) return stripped; + + const insertAt = boilerplateIndex !== -1 ? boilerplateIndex + 1 : lastAssistantToolUseIndex + 1; + const synthetic = createUserMessage({ + content: viewedAgentTask.prompt, + timestamp: new Date(viewedAgentTask.startTime).toISOString(), + }); + return [...stripped.slice(0, insertAt), synthetic, ...stripped.slice(insertAt)]; + }, [viewedAgentTask, rawAgentMessages]); const displayedMessages = viewedAgentTask - ? (viewedAgentTask.messages ?? []) + ? (displayedAgentMessages ?? []) : usesSyncMessages ? messages : deferredMessages; @@ -6286,6 +6373,7 @@ export function REPL({ voiceInterimRange={voice.interimRange} /> + )} {cursor && ( diff --git a/src/state/AppStateStore.ts b/src/state/AppStateStore.ts index e5f20efdac..92f6fb7351 100644 --- a/src/state/AppStateStore.ts +++ b/src/state/AppStateStore.ts @@ -85,6 +85,7 @@ export type FooterItem = | 'teams' | 'bridge' | 'companion' + | 'bg_agent' export type AppState = DeepImmutable<{ settings: SettingsJson @@ -97,6 +98,9 @@ export type AppState = DeepImmutable<{ // Optional - only present when ENABLE_AGENT_SWARMS is true (for dead code elimination) showTeammateMessagePreview?: boolean selectedIPAgentIndex: number + // Selection index for the bottom BackgroundAgentSelector. + // -1 = main, 0..N-1 = index into useBackgroundAgentTasks(). + selectedBgAgentIndex: number // CoordinatorTaskPanel selection: -1 = pill, 0 = main, 1..N = agent rows. // AppState (not local) so the panel can read it directly without prop-drilling // through PromptInput → PromptInputFooter. @@ -477,6 +481,7 @@ export function getDefaultAppState(): AppState { isBriefOnly: false, showTeammateMessagePreview: false, selectedIPAgentIndex: -1, + selectedBgAgentIndex: -1, coordinatorTaskIndex: -1, viewSelectionMode: 'none', footerSelection: null,