mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-30 22:00:53 +08:00
cli: add live subagent footer inspector to run
Keep direct-mode subagent activity in the footer so child sessions can be inspected.
This commit is contained in:
@@ -17,7 +17,15 @@ import path from "path"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import type { FooterApi, PermissionReply, QuestionReject, QuestionReply, RunDemo, RunPrompt } from "./types"
|
||||
import type {
|
||||
FooterApi,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunDemo,
|
||||
RunPrompt,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
const KINDS = ["text", "reasoning", "bash", "write", "edit", "patch", "task", "todo", "question", "error", "mix"]
|
||||
const PERMISSIONS = ["edit", "bash", "read", "task", "external", "doom"] as const
|
||||
@@ -115,6 +123,60 @@ function note(footer: FooterApi, text: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
function clearSubagent(footer: FooterApi): void {
|
||||
footer.event({
|
||||
type: "stream.subagent",
|
||||
state: {
|
||||
tabs: [],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function showSubagent(
|
||||
state: State,
|
||||
input: {
|
||||
sessionID: string
|
||||
partID: string
|
||||
callID: string
|
||||
label: string
|
||||
description: string
|
||||
status: "running" | "completed" | "error"
|
||||
title?: string
|
||||
toolCalls?: number
|
||||
commits: StreamCommit[]
|
||||
},
|
||||
) {
|
||||
state.footer.event({
|
||||
type: "stream.subagent",
|
||||
state: {
|
||||
tabs: [
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
partID: input.partID,
|
||||
callID: input.callID,
|
||||
label: input.label,
|
||||
description: input.description,
|
||||
status: input.status,
|
||||
title: input.title,
|
||||
toolCalls: input.toolCalls,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
[input.sessionID]: {
|
||||
sessionID: input.sessionID,
|
||||
commits: input.commits,
|
||||
},
|
||||
},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (!signal) {
|
||||
@@ -564,6 +626,68 @@ function emitTask(state: State): void {
|
||||
sessionId: "sub_demo_1",
|
||||
},
|
||||
})
|
||||
showSubagent(state, {
|
||||
sessionID: "sub_demo_1",
|
||||
partID: ref.part,
|
||||
callID: ref.call,
|
||||
label: "Explore",
|
||||
description: "Scan run/* for reducer touchpoints",
|
||||
status: "completed",
|
||||
title: "Reducer touchpoints found",
|
||||
toolCalls: 4,
|
||||
commits: [
|
||||
{
|
||||
kind: "user",
|
||||
text: "Scan run/* for reducer touchpoints",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
},
|
||||
{
|
||||
kind: "reasoning",
|
||||
text: "Thinking: tracing reducer and footer boundaries",
|
||||
phase: "progress",
|
||||
source: "reasoning",
|
||||
messageID: "sub_demo_msg_reasoning",
|
||||
partID: "sub_demo_reasoning_1",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
text: "running read",
|
||||
phase: "start",
|
||||
source: "tool",
|
||||
messageID: "sub_demo_msg_tool",
|
||||
partID: "sub_demo_tool_1",
|
||||
tool: "read",
|
||||
part: {
|
||||
id: "sub_demo_tool_1",
|
||||
type: "tool",
|
||||
sessionID: "sub_demo_1",
|
||||
messageID: "sub_demo_msg_tool",
|
||||
callID: "sub_demo_call_1",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
filePath: "packages/opencode/src/cli/cmd/run/stream.ts",
|
||||
offset: 1,
|
||||
limit: 200,
|
||||
},
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
text: "Footer updates flow through stream.ts into RunFooter",
|
||||
phase: "progress",
|
||||
source: "assistant",
|
||||
messageID: "sub_demo_msg_text",
|
||||
partID: "sub_demo_text_1",
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
function emitTodo(state: State): void {
|
||||
@@ -980,6 +1104,8 @@ export function createRunDemo(input: Input) {
|
||||
const list = text.split(/\s+/)
|
||||
const cmd = list[0] || ""
|
||||
|
||||
clearSubagent(state.footer)
|
||||
|
||||
if (cmd === "/help") {
|
||||
intro(state)
|
||||
return true
|
||||
|
||||
194
packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Normal file
194
packages/opencode/src/cli/cmd/run/footer.subagent.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import "opentui-spinner/solid"
|
||||
import { For, createMemo, createSignal } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "../tui/component/spinner"
|
||||
import { RunEntryContent, sameEntryGroup } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
|
||||
export const SUBAGENT_TAB_ROWS = 2
|
||||
export const SUBAGENT_INSPECTOR_ROWS = 8
|
||||
|
||||
function statusColor(theme: RunFooterTheme, status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return theme.success
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return theme.error
|
||||
}
|
||||
|
||||
return theme.highlight
|
||||
}
|
||||
|
||||
function statusIcon(status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return "●"
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return "◍"
|
||||
}
|
||||
|
||||
return "◔"
|
||||
}
|
||||
|
||||
export function RunFooterSubagentTabs(props: {
|
||||
tabs: FooterSubagentTab[]
|
||||
selected?: string
|
||||
theme: RunFooterTheme
|
||||
onToggle: (sessionID: string) => void
|
||||
}) {
|
||||
const [hover, setHover] = createSignal<string>()
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-subagent-tabs"
|
||||
width="100%"
|
||||
height={SUBAGENT_TAB_ROWS}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingBottom={1}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" gap={3} flexShrink={1} flexGrow={1}>
|
||||
{props.tabs.map((tab) => {
|
||||
const active = () => props.selected === tab.sessionID
|
||||
const hovered = () => hover() === tab.sessionID
|
||||
const emphasized = () => active() || hovered()
|
||||
return (
|
||||
<box
|
||||
paddingRight={1}
|
||||
onMouseOver={() => {
|
||||
setHover(tab.sessionID)
|
||||
}}
|
||||
onMouseOut={() => {
|
||||
if (hover() === tab.sessionID) {
|
||||
setHover(undefined)
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
props.onToggle(tab.sessionID)
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1} width="100%">
|
||||
{tab.status === "running" ? (
|
||||
<box flexShrink={0}>
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={statusColor(props.theme, tab.status)} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={statusColor(props.theme, tab.status)} wrapMode="none" truncate flexShrink={0}>
|
||||
{statusIcon(tab.status)}
|
||||
</text>
|
||||
)}
|
||||
<text fg={emphasized() ? props.theme.text : props.theme.muted} wrapMode="none" truncate>
|
||||
{tab.label}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
})}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunFooterSubagentBody(props: {
|
||||
active: () => boolean
|
||||
theme: () => RunTheme
|
||||
detail: () => FooterSubagentDetail | undefined
|
||||
width: () => number
|
||||
diffStyle?: RunDiffStyle
|
||||
onCycle: (dir: -1 | 1) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const commits = createMemo(() => props.detail()?.commits ?? [])
|
||||
const entries = createMemo(() => {
|
||||
return commits().map((commit, index, list) => ({
|
||||
commit,
|
||||
gap: index > 0 && !sameEntryGroup(list[index - 1], commit),
|
||||
}))
|
||||
})
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (!props.active()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "tab") {
|
||||
event.preventDefault()
|
||||
props.onCycle(event.shift ? -1 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "up" || event.name === "k") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
event.preventDefault()
|
||||
scroll?.scrollBy(1)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
id="run-direct-footer-subagent"
|
||||
width="100%"
|
||||
height="100%"
|
||||
flexDirection="column"
|
||||
backgroundColor={props.theme().footer.surface}
|
||||
>
|
||||
<box paddingTop={1} paddingLeft={1} paddingRight={3} paddingBottom={1} flexDirection="column" flexGrow={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
stickyScroll={true}
|
||||
stickyStart="bottom"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme().footer.surface,
|
||||
foregroundColor: props.theme().footer.line,
|
||||
},
|
||||
}}
|
||||
ref={(item) => {
|
||||
scroll = item as ScrollBoxRenderable
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
<For each={entries()}>
|
||||
{(item) => (
|
||||
<box flexDirection="column" gap={0}>
|
||||
{item.gap ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent
|
||||
commit={item.commit}
|
||||
theme={props.theme()}
|
||||
opts={{ diffStyle: props.diffStyle }}
|
||||
width={props.width()}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
{entries().length === 0 ? (
|
||||
<text fg={props.theme().footer.muted} wrapMode="word">
|
||||
No subagent activity yet
|
||||
</text>
|
||||
) : null}
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -26,13 +26,13 @@
|
||||
import { CliRenderEvents, type CliRenderer } from "@opentui/core"
|
||||
import { render } from "@opentui/solid"
|
||||
import { createComponent, createSignal, type Accessor, type Setter } from "solid-js"
|
||||
import { SUBAGENT_INSPECTOR_ROWS, SUBAGENT_TAB_ROWS } from "./footer.subagent"
|
||||
import { PROMPT_MAX_ROWS, TEXTAREA_MIN_ROWS } from "./footer.prompt"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import { RunFooterView } from "./footer.view"
|
||||
import { normalizeEntry } from "./scrollback.format"
|
||||
import { entryWriter } from "./scrollback"
|
||||
import { spacerWriter } from "./scrollback.writer"
|
||||
import { toolView } from "./tool"
|
||||
import { sameEntryGroup, spacerWriter } from "./scrollback.writer"
|
||||
import type { RunTheme } from "./theme"
|
||||
import type {
|
||||
RunAgent,
|
||||
@@ -40,9 +40,11 @@ import type {
|
||||
FooterEvent,
|
||||
FooterKeybinds,
|
||||
FooterPatch,
|
||||
FooterPromptRoute,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
@@ -74,11 +76,21 @@ type RunFooterOptions = {
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onInterrupt?: () => void
|
||||
onExit?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
}
|
||||
|
||||
const PERMISSION_ROWS = 12
|
||||
const QUESTION_ROWS = 14
|
||||
|
||||
function createEmptySubagentState(): FooterSubagentState {
|
||||
return {
|
||||
tabs: [],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export class RunFooter implements FooterApi {
|
||||
private closed = false
|
||||
private destroyed = false
|
||||
@@ -98,6 +110,10 @@ export class RunFooter implements FooterApi {
|
||||
private setState: Setter<FooterState>
|
||||
private view: Accessor<FooterView>
|
||||
private setView: Setter<FooterView>
|
||||
private subagent: Accessor<FooterSubagentState>
|
||||
private setSubagent: Setter<FooterSubagentState>
|
||||
private promptRoute: FooterPromptRoute = { type: "composer" }
|
||||
private tabsVisible = false
|
||||
private interruptTimeout: NodeJS.Timeout | undefined
|
||||
private exitTimeout: NodeJS.Timeout | undefined
|
||||
private interruptHint: string
|
||||
@@ -122,6 +138,9 @@ export class RunFooter implements FooterApi {
|
||||
const [view, setView] = createSignal<FooterView>({ type: "prompt" })
|
||||
this.view = view
|
||||
this.setView = setView
|
||||
const [subagent, setSubagent] = createSignal<FooterSubagentState>(createEmptySubagentState())
|
||||
this.subagent = subagent
|
||||
this.setSubagent = setSubagent
|
||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||
this.interruptHint = printableBinding(options.keybinds.interrupt, options.keybinds.leader) || "esc"
|
||||
|
||||
@@ -133,11 +152,11 @@ export class RunFooter implements FooterApi {
|
||||
directory: options.directory,
|
||||
state: this.state,
|
||||
view: this.view,
|
||||
subagent: this.subagent,
|
||||
findFiles: options.findFiles,
|
||||
agents: () => options.agents,
|
||||
resources: () => options.resources,
|
||||
theme: options.theme.footer,
|
||||
block: options.theme.block,
|
||||
theme: options.theme,
|
||||
diffStyle: options.diffStyle,
|
||||
keybinds: options.keybinds,
|
||||
history: options.history,
|
||||
@@ -151,7 +170,9 @@ export class RunFooter implements FooterApi {
|
||||
onExitRequest: this.handleExit,
|
||||
onExit: () => this.close(),
|
||||
onRows: this.syncRows,
|
||||
onLayout: this.syncLayout,
|
||||
onStatus: this.setStatus,
|
||||
onSubagentSelect: options.onSubagentSelect,
|
||||
}),
|
||||
this.renderer as unknown as Parameters<typeof render>[1],
|
||||
).catch(() => {
|
||||
@@ -241,6 +262,16 @@ export class RunFooter implements FooterApi {
|
||||
return
|
||||
}
|
||||
|
||||
if (next.type === "stream.subagent") {
|
||||
if (this.destroyed || this.renderer.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setSubagent(next.state)
|
||||
this.applyHeight()
|
||||
return
|
||||
}
|
||||
|
||||
this.present(next.view)
|
||||
}
|
||||
|
||||
@@ -382,12 +413,18 @@ export class RunFooter implements FooterApi {
|
||||
// get fixed extra rows; the prompt view scales with textarea line count.
|
||||
private applyHeight(): void {
|
||||
const type = this.view().type
|
||||
const tabs = this.tabsVisible ? SUBAGENT_TAB_ROWS : 0
|
||||
const height =
|
||||
type === "permission"
|
||||
? this.base + PERMISSION_ROWS
|
||||
: type === "question"
|
||||
? this.base + QUESTION_ROWS
|
||||
: Math.max(this.base + TEXTAREA_MIN_ROWS, Math.min(this.base + PROMPT_MAX_ROWS, this.base + this.rows))
|
||||
: this.promptRoute.type === "subagent"
|
||||
? this.base + tabs + SUBAGENT_INSPECTOR_ROWS
|
||||
: Math.max(
|
||||
this.base + TEXTAREA_MIN_ROWS,
|
||||
Math.min(this.base + tabs + PROMPT_MAX_ROWS, this.base + tabs + this.rows),
|
||||
)
|
||||
|
||||
if (height !== this.renderer.footerHeight) {
|
||||
this.renderer.footerHeight = height
|
||||
@@ -410,6 +447,14 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
}
|
||||
|
||||
private syncLayout = (next: { route: FooterPromptRoute; tabs: boolean }): void => {
|
||||
this.promptRoute = next.route
|
||||
this.tabsVisible = next.tabs
|
||||
if (this.view().type === "prompt") {
|
||||
this.applyHeight()
|
||||
}
|
||||
}
|
||||
|
||||
private handlePrompt = (input: RunPrompt): boolean => {
|
||||
if (this.isClosed) {
|
||||
return false
|
||||
@@ -586,7 +631,7 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
|
||||
for (const item of this.queue.splice(0)) {
|
||||
const same = sameGroup(this.tail, item)
|
||||
const same = sameEntryGroup(this.tail, item)
|
||||
if (this.wrote && !same) {
|
||||
this.renderer.writeToScrollback(spacerWriter())
|
||||
}
|
||||
@@ -597,40 +642,3 @@ export class RunFooter implements FooterApi {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function snap(commit: StreamCommit): boolean {
|
||||
const tool = commit.tool ?? commit.part?.tool
|
||||
return (
|
||||
commit.kind === "tool" &&
|
||||
commit.phase === "final" &&
|
||||
(commit.toolState ?? commit.part?.state.status) === "completed" &&
|
||||
typeof tool === "string" &&
|
||||
Boolean(toolView(tool).snap)
|
||||
)
|
||||
}
|
||||
|
||||
function groupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
return
|
||||
}
|
||||
|
||||
if (snap(commit)) {
|
||||
return `tool:${commit.partID}:final`
|
||||
}
|
||||
|
||||
return `${commit.kind}:${commit.partID}`
|
||||
}
|
||||
|
||||
function sameGroup(a: StreamCommit | undefined, b: StreamCommit): boolean {
|
||||
if (!a) {
|
||||
return false
|
||||
}
|
||||
|
||||
const left = groupKey(a)
|
||||
const right = groupKey(b)
|
||||
if (left && right && left === right) {
|
||||
return true
|
||||
}
|
||||
|
||||
return a.kind === "tool" && a.phase === "start" && b.kind === "tool" && b.phase === "start"
|
||||
}
|
||||
|
||||
@@ -11,26 +11,29 @@
|
||||
// The view itself is stateless except for derived memos.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import "opentui-spinner/solid"
|
||||
import { createColors, createFrames } from "../tui/ui/spinner"
|
||||
import { RunFooterSubagentBody, RunFooterSubagentTabs } from "./footer.subagent"
|
||||
import { RunPromptAutocomplete, RunPromptBody, createPromptState, hintFlags } from "./footer.prompt"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunQuestionBody } from "./footer.question"
|
||||
import { printableBinding } from "./prompt.shared"
|
||||
import type {
|
||||
FooterKeybinds,
|
||||
FooterPromptRoute,
|
||||
RunAgent,
|
||||
RunPrompt,
|
||||
RunResource,
|
||||
FooterState,
|
||||
FooterSubagentState,
|
||||
FooterView,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunDiffStyle,
|
||||
} from "./types"
|
||||
import { RUN_THEME_FALLBACK, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "./theme"
|
||||
|
||||
const EMPTY_BORDER = {
|
||||
topLeft: "",
|
||||
@@ -53,8 +56,8 @@ type RunFooterViewProps = {
|
||||
resources: () => RunResource[]
|
||||
state: () => FooterState
|
||||
view?: () => FooterView
|
||||
theme?: RunFooterTheme
|
||||
block?: RunBlockTheme
|
||||
subagent?: () => FooterSubagentState
|
||||
theme?: RunTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
keybinds: FooterKeybinds
|
||||
history?: RunPrompt[]
|
||||
@@ -68,7 +71,9 @@ type RunFooterViewProps = {
|
||||
onExitRequest?: () => boolean
|
||||
onExit: () => void
|
||||
onRows: (rows: number) => void
|
||||
onLayout: (input: { route: FooterPromptRoute; tabs: boolean }) => void
|
||||
onStatus: (text: string) => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
}
|
||||
|
||||
export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
||||
@@ -76,7 +81,33 @@ export { TEXTAREA_MIN_ROWS, TEXTAREA_MAX_ROWS } from "./footer.prompt"
|
||||
export function RunFooterView(props: RunFooterViewProps) {
|
||||
const term = useTerminalDimensions()
|
||||
const active = createMemo<FooterView>(() => props.view?.() ?? { type: "prompt" })
|
||||
const prompt = createMemo(() => active().type === "prompt")
|
||||
const subagent = createMemo<FooterSubagentState>(() => {
|
||||
return (
|
||||
props.subagent?.() ?? {
|
||||
tabs: [],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
}
|
||||
)
|
||||
})
|
||||
const [route, setRoute] = createSignal<FooterPromptRoute>({ type: "composer" })
|
||||
const prompt = createMemo(() => active().type === "prompt" && route().type === "composer")
|
||||
const inspecting = createMemo(() => active().type === "prompt" && route().type === "subagent")
|
||||
const selected = createMemo(() => {
|
||||
const current = route()
|
||||
return current.type === "subagent" ? current.sessionID : undefined
|
||||
})
|
||||
const tabs = createMemo(() => subagent().tabs)
|
||||
const showTabs = createMemo(() => active().type === "prompt" && tabs().length > 0)
|
||||
const detail = createMemo(() => {
|
||||
const current = route()
|
||||
if (current.type !== "subagent") {
|
||||
return
|
||||
}
|
||||
|
||||
return subagent().details[current.sessionID]
|
||||
})
|
||||
const variant = createMemo(() => printableBinding(props.keybinds.variantCycle, props.keybinds.leader))
|
||||
const interrupt = createMemo(() => printableBinding(props.keybinds.interrupt, props.keybinds.leader))
|
||||
const hints = createMemo(() => hintFlags(term().width))
|
||||
@@ -87,8 +118,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const duration = createMemo(() => props.state().duration)
|
||||
const usage = createMemo(() => props.state().usage)
|
||||
const interruptKey = createMemo(() => interrupt() || "/exit")
|
||||
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK.footer)
|
||||
const block = createMemo(() => props.block ?? RUN_THEME_FALLBACK.block)
|
||||
const runTheme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
||||
const theme = createMemo(() => runTheme().footer)
|
||||
const block = createMemo(() => runTheme().block)
|
||||
const spin = createMemo(() => {
|
||||
return {
|
||||
frames: createFrames({
|
||||
@@ -113,6 +145,50 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const view = active()
|
||||
return view.type === "question" ? view : undefined
|
||||
})
|
||||
const promptView = createMemo(() => {
|
||||
if (active().type !== "prompt") {
|
||||
return active().type
|
||||
}
|
||||
|
||||
return route().type === "composer" ? "prompt" : "subagent"
|
||||
})
|
||||
|
||||
const openTab = (sessionID: string) => {
|
||||
setRoute({ type: "subagent", sessionID })
|
||||
props.onSubagentSelect?.(sessionID)
|
||||
}
|
||||
|
||||
const closeTab = () => {
|
||||
setRoute({ type: "composer" })
|
||||
props.onSubagentSelect?.(undefined)
|
||||
}
|
||||
|
||||
const toggleTab = (sessionID: string) => {
|
||||
const current = route()
|
||||
if (current.type === "subagent" && current.sessionID === sessionID) {
|
||||
closeTab()
|
||||
return
|
||||
}
|
||||
|
||||
openTab(sessionID)
|
||||
}
|
||||
|
||||
const cycleTab = (dir: -1 | 1) => {
|
||||
if (tabs().length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const routeState = route()
|
||||
const current =
|
||||
routeState.type === "subagent" ? tabs().findIndex((item) => item.sessionID === routeState.sessionID) : -1
|
||||
const index = current === -1 ? 0 : (current + dir + tabs().length) % tabs().length
|
||||
const next = tabs()[index]
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
|
||||
openTab(next.sessionID)
|
||||
}
|
||||
const composer = createPromptState({
|
||||
directory: props.directory,
|
||||
findFiles: props.findFiles,
|
||||
@@ -120,7 +196,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
resources: props.resources,
|
||||
keybinds: props.keybinds,
|
||||
state: props.state,
|
||||
view: () => active().type,
|
||||
view: promptView,
|
||||
prompt,
|
||||
width: () => term().width,
|
||||
theme,
|
||||
@@ -133,7 +209,27 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
onRows: props.onRows,
|
||||
onStatus: props.onStatus,
|
||||
})
|
||||
const menu = createMemo(() => active().type === "prompt" && composer.visible())
|
||||
const menu = createMemo(() => prompt() && composer.visible())
|
||||
|
||||
createEffect(() => {
|
||||
const current = route()
|
||||
if (current.type === "composer") {
|
||||
return
|
||||
}
|
||||
|
||||
if (tabs().some((item) => item.sessionID === current.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeTab()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onLayout({
|
||||
route: route(),
|
||||
tabs: tabs().length > 0,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
@@ -148,187 +244,240 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
>
|
||||
<box id="run-direct-footer-top-spacer" width="100%" height={1} flexShrink={0} backgroundColor="transparent" />
|
||||
|
||||
<box
|
||||
id="run-direct-footer-composer-frame"
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-composer-area"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
paddingLeft={0}
|
||||
paddingRight={0}
|
||||
paddingTop={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme().surface}
|
||||
gap={0}
|
||||
>
|
||||
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
|
||||
<Switch>
|
||||
<Match when={active().type === "prompt"}>
|
||||
<RunPromptBody
|
||||
theme={theme}
|
||||
placeholder={composer.placeholder}
|
||||
bindings={composer.bindings}
|
||||
onSubmit={composer.onSubmit}
|
||||
onKeyDown={composer.onKeyDown}
|
||||
onContentChange={composer.onContentChange}
|
||||
bind={composer.bind}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
<RunPermissionBody
|
||||
request={permission()!.request}
|
||||
theme={theme()}
|
||||
block={block()}
|
||||
diffStyle={props.diffStyle}
|
||||
onReply={props.onPermissionReply}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "question"}>
|
||||
<RunQuestionBody
|
||||
request={question()!.request}
|
||||
theme={theme()}
|
||||
onReply={props.onQuestionReply}
|
||||
onReject={props.onQuestionReject}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-meta-row"
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
>
|
||||
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.agent}
|
||||
</text>
|
||||
<text id="run-direct-footer-model" fg={theme().text} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{props.state().model}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-line-6"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
backgroundColor="transparent"
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "╹",
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-line-6-fill"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={theme().surface}
|
||||
backgroundColor={menu() ? theme().shade : "transparent"}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
horizontal: "▀",
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
<Show when={showTabs()}>
|
||||
<RunFooterSubagentTabs tabs={tabs()} selected={selected()} theme={theme()} onToggle={toggleTab} />
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={menu()}
|
||||
when={inspecting()}
|
||||
fallback={
|
||||
<box
|
||||
id="run-direct-footer-row"
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={busy() || exiting()}>
|
||||
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={exiting()}>
|
||||
<text id="run-direct-footer-hint-exit" fg={theme().highlight} wrapMode="none" truncate marginLeft={1}>
|
||||
Press Ctrl-c again to exit
|
||||
<box width="100%" flexDirection="column" gap={0}>
|
||||
<box
|
||||
id="run-direct-footer-composer-frame"
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-composer-area"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
paddingLeft={0}
|
||||
paddingRight={0}
|
||||
paddingTop={0}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme().surface}
|
||||
gap={0}
|
||||
>
|
||||
<box id="run-direct-footer-body" width="100%" flexGrow={1} flexShrink={1} flexDirection="column">
|
||||
<Switch>
|
||||
<Match when={active().type === "prompt" && route().type === "composer"}>
|
||||
<RunPromptBody
|
||||
theme={theme}
|
||||
placeholder={composer.placeholder}
|
||||
bindings={composer.bindings}
|
||||
onSubmit={composer.onSubmit}
|
||||
onKeyDown={composer.onKeyDown}
|
||||
onContentChange={composer.onContentChange}
|
||||
bind={composer.bind}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "permission"}>
|
||||
<RunPermissionBody
|
||||
request={permission()!.request}
|
||||
theme={theme()}
|
||||
block={block()}
|
||||
diffStyle={props.diffStyle}
|
||||
onReply={props.onPermissionReply}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={active().type === "question"}>
|
||||
<RunQuestionBody
|
||||
request={question()!.request}
|
||||
theme={theme()}
|
||||
onReply={props.onQuestionReply}
|
||||
onReject={props.onQuestionReject}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-meta-row"
|
||||
width="100%"
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
paddingTop={1}
|
||||
>
|
||||
<text id="run-direct-footer-agent" fg={theme().highlight} wrapMode="none" truncate flexShrink={0}>
|
||||
{props.agent}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={busy() && !exiting()}>
|
||||
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
|
||||
<text
|
||||
id="run-direct-footer-hint-interrupt"
|
||||
fg={armed() ? theme().highlight : theme().text}
|
||||
id="run-direct-footer-model"
|
||||
fg={theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
{interruptKey()}{" "}
|
||||
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
|
||||
{armed() ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!busy() && !exiting() && duration().length > 0}>
|
||||
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
|
||||
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
|
||||
▣
|
||||
</text>
|
||||
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
|
||||
·
|
||||
</text>
|
||||
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
|
||||
{duration()}
|
||||
{props.state().model}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
|
||||
<box id="run-direct-footer-hint-group" flexDirection="row" gap={2} flexShrink={0} justifyContent="flex-end">
|
||||
<Show when={queue() > 0}>
|
||||
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
|
||||
{queue()} queued
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={usage().length > 0}>
|
||||
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
|
||||
{usage()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={variant().length > 0 && hints().variant}>
|
||||
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
|
||||
{variant()} variant
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box
|
||||
id="run-direct-footer-line-6"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
backgroundColor="transparent"
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "╹",
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box
|
||||
id="run-direct-footer-line-6-fill"
|
||||
width="100%"
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={theme().surface}
|
||||
backgroundColor={menu() ? theme().shade : "transparent"}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
horizontal: "▀",
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={menu()}
|
||||
fallback={
|
||||
<box
|
||||
id="run-direct-footer-row"
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Show when={busy() || exiting()}>
|
||||
<box id="run-direct-footer-hint-left" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={exiting()}>
|
||||
<text
|
||||
id="run-direct-footer-hint-exit"
|
||||
fg={theme().highlight}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
marginLeft={1}
|
||||
>
|
||||
Press Ctrl-c again to exit
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
<Show when={busy() && !exiting()}>
|
||||
<box id="run-direct-footer-status-spinner" marginLeft={1} flexShrink={0}>
|
||||
<spinner color={spin().color} frames={spin().frames} interval={40} />
|
||||
</box>
|
||||
|
||||
<text
|
||||
id="run-direct-footer-hint-interrupt"
|
||||
fg={armed() ? theme().highlight : theme().text}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
>
|
||||
{interruptKey()}{" "}
|
||||
<span style={{ fg: armed() ? theme().highlight : theme().muted }}>
|
||||
{armed() ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!busy() && !exiting() && duration().length > 0}>
|
||||
<box id="run-direct-footer-duration" flexDirection="row" gap={2} flexShrink={0} marginLeft={1}>
|
||||
<text id="run-direct-footer-duration-mark" fg={theme().muted} wrapMode="none" truncate>
|
||||
▣
|
||||
</text>
|
||||
<box id="run-direct-footer-duration-tail" flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text id="run-direct-footer-duration-dot" fg={theme().muted} wrapMode="none" truncate>
|
||||
·
|
||||
</text>
|
||||
<text id="run-direct-footer-duration-value" fg={theme().muted} wrapMode="none" truncate>
|
||||
{duration()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<box id="run-direct-footer-spacer" flexGrow={1} flexShrink={1} backgroundColor="transparent" />
|
||||
|
||||
<box
|
||||
id="run-direct-footer-hint-group"
|
||||
flexDirection="row"
|
||||
gap={2}
|
||||
flexShrink={0}
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Show when={queue() > 0}>
|
||||
<text id="run-direct-footer-queue" fg={theme().muted} wrapMode="none" truncate>
|
||||
{queue()} queued
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={usage().length > 0}>
|
||||
<text id="run-direct-footer-usage" fg={theme().muted} wrapMode="none" truncate>
|
||||
{usage()}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={variant().length > 0 && hints().variant}>
|
||||
<text id="run-direct-footer-hint-variant" fg={theme().muted} wrapMode="none" truncate>
|
||||
{variant()} variant
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<RunPromptAutocomplete theme={theme} options={composer.options} selected={composer.selected} />
|
||||
<box
|
||||
id="run-direct-footer-subagent-frame"
|
||||
width="100%"
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().highlight}
|
||||
customBorderChars={{
|
||||
...EMPTY_BORDER,
|
||||
vertical: "┃",
|
||||
}}
|
||||
>
|
||||
<RunFooterSubagentBody
|
||||
active={inspecting}
|
||||
theme={runTheme}
|
||||
detail={detail}
|
||||
width={() => term().width}
|
||||
diffStyle={props.diffStyle}
|
||||
onCycle={cycleTab}
|
||||
onClose={closeTab}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -63,6 +63,7 @@ export type LifecycleInput = {
|
||||
onQuestionReject: (input: QuestionReject) => void | Promise<void>
|
||||
onCycleVariant?: () => CycleResult | void
|
||||
onInterrupt?: () => void
|
||||
onSubagentSelect?: (sessionID: string | undefined) => void
|
||||
}
|
||||
|
||||
export type Lifecycle = {
|
||||
@@ -153,7 +154,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
maxFps: 60,
|
||||
useMouse: false,
|
||||
useMouse: true,
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
exitOnCtrlC: false,
|
||||
@@ -211,6 +212,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
onQuestionReject: input.onQuestionReject,
|
||||
onCycleVariant: input.onCycleVariant,
|
||||
onInterrupt: input.onInterrupt,
|
||||
onSubagentSelect: input.onSubagentSelect,
|
||||
})
|
||||
|
||||
const sigint = () => {
|
||||
|
||||
@@ -90,6 +90,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
])
|
||||
shown = !session.first
|
||||
let activeVariant = resolveVariant(ctx.variant, session.variant, savedVariant, variants)
|
||||
let selectSubagent: ((sessionID: string | undefined) => void) | undefined
|
||||
|
||||
const shell = await createRuntimeLifecycle({
|
||||
directory: ctx.directory,
|
||||
@@ -160,6 +161,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
aborting = false
|
||||
})
|
||||
},
|
||||
onSubagentSelect: (sessionID) => {
|
||||
selectSubagent?.(sessionID)
|
||||
log?.write("subagent.select", {
|
||||
sessionID,
|
||||
})
|
||||
},
|
||||
})
|
||||
const footer = shell.footer
|
||||
|
||||
@@ -209,6 +216,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
||||
footer,
|
||||
trace: log,
|
||||
})
|
||||
selectSubagent = stream.selectSubagent
|
||||
|
||||
try {
|
||||
if (demo) {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { createScrollbackWriter, type JSX } from "@opentui/solid"
|
||||
import { For, Show } from "solid-js"
|
||||
import * as Filesystem from "../../../util/filesystem"
|
||||
import { toolDiffView, toolFiletype, toolFrame, toolSnapshot } from "./tool"
|
||||
import { toolDiffView, toolFiletype, toolFrame, toolSnapshot, toolView } from "./tool"
|
||||
import { clean, normalizeEntry } from "./scrollback.format"
|
||||
import { RUN_THEME_FALLBACK, type RunEntryTheme, type RunTheme } from "./theme"
|
||||
import type { ScrollbackOptions, StreamCommit } from "./types"
|
||||
@@ -426,23 +426,165 @@ function QuestionTool(props: { theme: RunTheme; data: QuestionInput }) {
|
||||
)
|
||||
}
|
||||
|
||||
function textWriter(body: string, commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
|
||||
const style = look(commit, theme)
|
||||
return (ctx) =>
|
||||
fit(
|
||||
createScrollbackWriter(() => <TextEntry body={body} fg={style.fg} attrs={style.attrs} />, {
|
||||
width: cols(ctx),
|
||||
startOnNewLine: flags.startOnNewLine,
|
||||
trailingNewline: flags.trailingNewline,
|
||||
})(ctx),
|
||||
ctx,
|
||||
)
|
||||
function snapCommit(commit: StreamCommit) {
|
||||
const state = commit.toolState ?? commit.part?.state.status
|
||||
return (
|
||||
commit.kind === "tool" &&
|
||||
commit.phase === "final" &&
|
||||
state === "completed" &&
|
||||
Boolean(toolView(commit.tool ?? commit.part?.tool).snap)
|
||||
)
|
||||
}
|
||||
|
||||
function reasoningWriter(body: string, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
|
||||
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
||||
if (!commit.partID) {
|
||||
return
|
||||
}
|
||||
|
||||
if (snapCommit(commit)) {
|
||||
return `tool:${commit.partID}:final`
|
||||
}
|
||||
|
||||
return `${commit.kind}:${commit.partID}`
|
||||
}
|
||||
|
||||
export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
|
||||
if (!left) {
|
||||
return false
|
||||
}
|
||||
|
||||
const current = entryGroupKey(left)
|
||||
const next = entryGroupKey(right)
|
||||
if (current && next && current === next) {
|
||||
return true
|
||||
}
|
||||
|
||||
return left.kind === "tool" && left.phase === "start" && right.kind === "tool" && right.phase === "start"
|
||||
}
|
||||
|
||||
export function RunEntryTextContent(props: { commit: StreamCommit; theme: RunEntryTheme }) {
|
||||
const body = normalizeEntry(props.commit)
|
||||
if (props.commit.kind === "reasoning") {
|
||||
return <ReasoningEntry body={body} theme={props.theme} />
|
||||
}
|
||||
|
||||
const style = look(props.commit, props.theme)
|
||||
return <TextEntry body={body} fg={style.fg} attrs={style.attrs} />
|
||||
}
|
||||
|
||||
export function RunEntrySnapContent(props: {
|
||||
commit: StreamCommit
|
||||
theme: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
width?: number
|
||||
}) {
|
||||
const raw = clean(props.commit.text)
|
||||
const snap = toolSnapshot(props.commit, raw)
|
||||
if (!snap) {
|
||||
return <RunEntryTextContent commit={props.commit} theme={props.theme.entry} />
|
||||
}
|
||||
|
||||
const info = toolFrame(props.commit, raw)
|
||||
if (snap.kind === "code") {
|
||||
return (
|
||||
<CodeTool
|
||||
theme={props.theme}
|
||||
data={{
|
||||
title: snap.title,
|
||||
content: snap.content,
|
||||
filetype: toolFiletype(snap.file),
|
||||
diagnostics: diagnostics(info.meta, snap.file ?? ""),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "diff") {
|
||||
const list = snap.items
|
||||
.map((item) => {
|
||||
if (!item.diff.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
diff: item.diff,
|
||||
filetype: toolFiletype(item.file),
|
||||
deletions: item.deletions,
|
||||
diagnostics: diagnostics(info.meta, item.file ?? ""),
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
|
||||
if (list.length === 0) {
|
||||
return <RunEntryTextContent commit={props.commit} theme={props.theme.entry} />
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<For each={list}>
|
||||
{(item) => (
|
||||
<DiffTool theme={props.theme} data={item} view={toolDiffView(props.width ?? 80, props.opts?.diffStyle)} />
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "task") {
|
||||
return (
|
||||
<TaskTool
|
||||
theme={props.theme}
|
||||
data={{
|
||||
title: snap.title,
|
||||
rows: snap.rows,
|
||||
tail: snap.tail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "todo") {
|
||||
return (
|
||||
<TodoTool
|
||||
theme={props.theme}
|
||||
data={{
|
||||
items: snap.items,
|
||||
tail: snap.tail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QuestionTool
|
||||
theme={props.theme}
|
||||
data={{
|
||||
items: snap.items,
|
||||
tail: snap.tail,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function RunEntryContent(props: {
|
||||
commit: StreamCommit
|
||||
theme?: RunTheme
|
||||
opts?: ScrollbackOptions
|
||||
width?: number
|
||||
}) {
|
||||
const theme = props.theme ?? RUN_THEME_FALLBACK
|
||||
if (snapCommit(props.commit)) {
|
||||
return <RunEntrySnapContent commit={props.commit} theme={theme} opts={props.opts} width={props.width} />
|
||||
}
|
||||
|
||||
return <RunEntryTextContent commit={props.commit} theme={theme.entry} />
|
||||
}
|
||||
|
||||
function textWriter(commit: StreamCommit, theme: RunEntryTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
fit(
|
||||
createScrollbackWriter(() => <ReasoningEntry body={body} theme={theme} />, {
|
||||
createScrollbackWriter(() => <RunEntryTextContent commit={commit} theme={theme} />, {
|
||||
width: cols(ctx),
|
||||
startOnNewLine: flags.startOnNewLine,
|
||||
trailingNewline: flags.trailingNewline,
|
||||
@@ -468,35 +610,6 @@ function textBlockWriter(body: string, theme: RunEntryTheme): ScrollbackWriter {
|
||||
})
|
||||
}
|
||||
|
||||
function codeWriter(data: CodeInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) => full(() => <CodeTool theme={theme} data={data} />, ctx, flags)
|
||||
}
|
||||
|
||||
function diffWriter(list: DiffInput[], theme: RunTheme, flags: Flags, view: "unified" | "split"): ScrollbackWriter {
|
||||
return (ctx) =>
|
||||
full(
|
||||
() => (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<For each={list}>{(data) => <DiffTool theme={theme} data={data} view={view} />}</For>
|
||||
</box>
|
||||
),
|
||||
ctx,
|
||||
flags,
|
||||
)
|
||||
}
|
||||
|
||||
function taskWriter(data: TaskInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) => full(() => <TaskTool theme={theme} data={data} />, ctx, flags)
|
||||
}
|
||||
|
||||
function todoWriter(data: TodoInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) => full(() => <TodoTool theme={theme} data={data} />, ctx, flags)
|
||||
}
|
||||
|
||||
function questionWriter(data: QuestionInput, theme: RunTheme, flags: Flags): ScrollbackWriter {
|
||||
return (ctx) => full(() => <QuestionTool theme={theme} data={data} />, ctx, flags)
|
||||
}
|
||||
|
||||
function flags(commit: StreamCommit): Flags {
|
||||
if (commit.kind === "user") {
|
||||
return {
|
||||
@@ -540,13 +653,7 @@ function flags(commit: StreamCommit): Flags {
|
||||
}
|
||||
|
||||
export function textEntryWriter(commit: StreamCommit, theme: RunEntryTheme): ScrollbackWriter {
|
||||
const body = normalizeEntry(commit)
|
||||
const snap = flags(commit)
|
||||
if (commit.kind === "reasoning") {
|
||||
return reasoningWriter(body, theme, snap)
|
||||
}
|
||||
|
||||
return textWriter(body, commit, theme, snap)
|
||||
return textWriter(commit, theme, flags(commit))
|
||||
}
|
||||
|
||||
export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: ScrollbackOptions): ScrollbackWriter {
|
||||
@@ -555,81 +662,10 @@ export function snapEntryWriter(commit: StreamCommit, theme: RunTheme, opts: Scr
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
|
||||
const info = toolFrame(commit, clean(commit.text))
|
||||
const style = flags(commit)
|
||||
|
||||
if (snap.kind === "code") {
|
||||
return codeWriter(
|
||||
{
|
||||
title: snap.title,
|
||||
content: snap.content,
|
||||
filetype: toolFiletype(snap.file),
|
||||
diagnostics: diagnostics(info.meta, snap.file ?? ""),
|
||||
},
|
||||
theme,
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "diff") {
|
||||
if (snap.items.length === 0) {
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
|
||||
const list = snap.items
|
||||
.map((item) => {
|
||||
if (!item.diff.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
title: item.title,
|
||||
diff: item.diff,
|
||||
filetype: toolFiletype(item.file),
|
||||
deletions: item.deletions,
|
||||
diagnostics: diagnostics(info.meta, item.file ?? ""),
|
||||
}
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => Boolean(item))
|
||||
|
||||
if (list.length === 0) {
|
||||
return textEntryWriter(commit, theme.entry)
|
||||
}
|
||||
|
||||
return (ctx) => diffWriter(list, theme, style, toolDiffView(ctx.width, opts.diffStyle))(ctx)
|
||||
}
|
||||
|
||||
if (snap.kind === "task") {
|
||||
return taskWriter(
|
||||
{
|
||||
title: snap.title,
|
||||
rows: snap.rows,
|
||||
tail: snap.tail,
|
||||
},
|
||||
theme,
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
if (snap.kind === "todo") {
|
||||
return todoWriter(
|
||||
{
|
||||
items: snap.items,
|
||||
tail: snap.tail,
|
||||
},
|
||||
theme,
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
return questionWriter(
|
||||
{
|
||||
items: snap.items,
|
||||
tail: snap.tail,
|
||||
},
|
||||
theme,
|
||||
style,
|
||||
)
|
||||
return (ctx) =>
|
||||
full(() => <RunEntrySnapContent commit={commit} theme={theme} opts={opts} width={cols(ctx)} />, ctx, style)
|
||||
}
|
||||
|
||||
export function blockWriter(text: string, theme: RunEntryTheme = RUN_THEME_FALLBACK.entry): ScrollbackWriter {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
// `data.questions`. The footer shows whichever is first. When a reply
|
||||
// event arrives, the queue entry is removed and the footer falls back
|
||||
// to the next pending request or to the prompt view.
|
||||
import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import * as Locale from "../../../util/locale"
|
||||
import { toolView } from "./tool"
|
||||
import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types"
|
||||
@@ -44,7 +44,7 @@ type Tokens = {
|
||||
}
|
||||
}
|
||||
|
||||
type PartKind = "assistant" | "reasoning"
|
||||
type PartKind = "assistant" | "reasoning" | "user"
|
||||
type MessageRole = "assistant" | "user"
|
||||
type Dict = Record<string, unknown>
|
||||
type SessionCommit = StreamCommit
|
||||
@@ -63,6 +63,7 @@ type SessionCommit = StreamCommit
|
||||
// - end: part IDs whose time.end has arrived (part is finished)
|
||||
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
||||
export type SessionData = {
|
||||
includeUserText: boolean
|
||||
announced: boolean
|
||||
ids: Set<string>
|
||||
tools: Set<string>
|
||||
@@ -92,8 +93,13 @@ export type SessionDataOutput = {
|
||||
footer?: FooterOutput
|
||||
}
|
||||
|
||||
export function createSessionData(): SessionData {
|
||||
export function createSessionData(
|
||||
input: {
|
||||
includeUserText?: boolean
|
||||
} = {},
|
||||
): SessionData {
|
||||
return {
|
||||
includeUserText: input.includeUserText ?? false,
|
||||
announced: false,
|
||||
ids: new Set(),
|
||||
tools: new Set(),
|
||||
@@ -143,7 +149,7 @@ function formatUsage(
|
||||
return text
|
||||
}
|
||||
|
||||
function formatError(error: {
|
||||
export function formatError(error: {
|
||||
name?: string
|
||||
message?: string
|
||||
data?: {
|
||||
@@ -255,6 +261,33 @@ function remove<T extends { id: string }>(list: T[], id: string): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
export function bootstrapSessionData(input: {
|
||||
data: SessionData
|
||||
messages: Array<{
|
||||
parts: Part[]
|
||||
}>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
}) {
|
||||
for (const message of input.messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") {
|
||||
continue
|
||||
}
|
||||
|
||||
input.data.call.set(key(part.messageID, part.callID), part.state.input)
|
||||
}
|
||||
}
|
||||
|
||||
for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
upsert(input.data.permissions, enrichPermission(input.data, request))
|
||||
}
|
||||
|
||||
for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
upsert(input.data.questions, request)
|
||||
}
|
||||
}
|
||||
|
||||
function key(msg: string, call: string): string {
|
||||
return `${msg}:${call}`
|
||||
}
|
||||
@@ -360,7 +393,11 @@ function ready(data: SessionData, partID: string): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
return role === "assistant"
|
||||
if (role === "assistant") {
|
||||
return true
|
||||
}
|
||||
|
||||
return data.includeUserText && role === "user"
|
||||
}
|
||||
|
||||
function syncText(data: SessionData, partID: string, next: string) {
|
||||
@@ -458,7 +495,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string,
|
||||
kind,
|
||||
text: chunk,
|
||||
phase: "progress",
|
||||
source: kind,
|
||||
source: kind === "user" ? "system" : kind,
|
||||
messageID: msg,
|
||||
partID,
|
||||
})
|
||||
@@ -472,7 +509,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string,
|
||||
kind,
|
||||
text: "",
|
||||
phase: "final",
|
||||
source: kind,
|
||||
source: kind === "user" ? "system" : kind,
|
||||
messageID: msg,
|
||||
partID,
|
||||
interrupted: true,
|
||||
@@ -496,7 +533,7 @@ function replay(data: SessionData, commits: SessionCommit[], messageID: string,
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
if (role === "user" && !data.includeUserText) {
|
||||
data.ids.add(partID)
|
||||
drop(data, partID)
|
||||
continue
|
||||
@@ -507,6 +544,10 @@ function replay(data: SessionData, commits: SessionCommit[], messageID: string,
|
||||
continue
|
||||
}
|
||||
|
||||
if (role === "user" && kind === "assistant") {
|
||||
data.part.set(partID, "user")
|
||||
}
|
||||
|
||||
if (kind === "reasoning" && !thinking) {
|
||||
if (data.end.has(partID)) {
|
||||
data.ids.add(partID)
|
||||
@@ -577,7 +618,7 @@ export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
|
||||
}
|
||||
|
||||
const msg = data.msg.get(partID)
|
||||
if (msg && data.role.get(msg) === "user") {
|
||||
if (msg && data.role.get(msg) === "user" && !data.includeUserText) {
|
||||
data.ids.add(partID)
|
||||
drop(data, partID)
|
||||
continue
|
||||
@@ -785,7 +826,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
||||
|
||||
const msg = part.messageID
|
||||
const role = msg ? data.role.get(msg) : undefined
|
||||
if (role === "user") {
|
||||
if (role === "user" && part.type === "text" && !data.includeUserText) {
|
||||
data.ids.add(part.id)
|
||||
drop(data, part.id)
|
||||
return out(data, commits)
|
||||
@@ -799,7 +840,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
|
||||
return out(data, commits)
|
||||
}
|
||||
|
||||
data.part.set(part.id, kind)
|
||||
data.part.set(part.id, role === "user" && kind === "assistant" ? "user" : kind)
|
||||
syncText(data, part.id, part.text)
|
||||
|
||||
if (part.time?.end) {
|
||||
|
||||
@@ -13,9 +13,41 @@
|
||||
// We also re-check live session status before resolving an idle event so a
|
||||
// delayed idle from an older turn cannot complete a newer busy turn.
|
||||
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createSessionData, flushInterrupted, reduceSessionData } from "./session-data"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import type { FooterApi, RunFilePart, RunInput, RunPrompt, StreamCommit } from "./types"
|
||||
import {
|
||||
bootstrapSessionData,
|
||||
createSessionData,
|
||||
flushInterrupted,
|
||||
reduceSessionData,
|
||||
type SessionData,
|
||||
} from "./session-data"
|
||||
import {
|
||||
bootstrapSubagentCalls,
|
||||
bootstrapSubagentData,
|
||||
clearFinishedSubagents,
|
||||
createSubagentData,
|
||||
listSubagentPermissions,
|
||||
listSubagentQuestions,
|
||||
listSubagentTabs,
|
||||
reduceSubagentData,
|
||||
snapshotSelectedSubagentData,
|
||||
snapshotSubagentData,
|
||||
SUBAGENT_BOOTSTRAP_LIMIT,
|
||||
SUBAGENT_CALL_BOOTSTRAP_LIMIT,
|
||||
type SubagentData,
|
||||
} from "./subagent-data"
|
||||
import { traceFooterOutput, writeSessionOutput } from "./stream"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterOutput,
|
||||
FooterPatch,
|
||||
FooterSubagentState,
|
||||
FooterSubagentTab,
|
||||
FooterView,
|
||||
RunFilePart,
|
||||
RunInput,
|
||||
RunPrompt,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
@@ -52,6 +84,7 @@ export type SessionTurnInput = {
|
||||
|
||||
export type SessionTransport = {
|
||||
runPromptTurn(input: SessionTurnInput): Promise<void>
|
||||
selectSubagent(sessionID: string | undefined): void
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -162,6 +195,161 @@ export function formatUnknownError(error: unknown): string {
|
||||
return "unknown error"
|
||||
}
|
||||
|
||||
function sameView(a: FooterView, b: FooterView) {
|
||||
if (a.type !== b.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (a.type === "prompt" && b.type === "prompt") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a.type === "prompt" || b.type === "prompt") {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.request === b.request
|
||||
}
|
||||
|
||||
function blockerStatus(view: FooterView) {
|
||||
if (view.type === "permission") {
|
||||
return "awaiting permission"
|
||||
}
|
||||
|
||||
if (view.type === "question") {
|
||||
return "awaiting answer"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
function blockerOrder(order: Map<string, number>, id: string) {
|
||||
return order.get(id) ?? Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
function firstByOrder<T extends { id: string }>(left: T[], right: T[], order: Map<string, number>) {
|
||||
return [...left, ...right].sort((a, b) => {
|
||||
const next = blockerOrder(order, a.id) - blockerOrder(order, b.id)
|
||||
if (next !== 0) {
|
||||
return next
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id)
|
||||
})[0]
|
||||
}
|
||||
|
||||
function pickView(data: SessionData, subagent: SubagentData, order: Map<string, number>): FooterView {
|
||||
const permission = firstByOrder(data.permissions, listSubagentPermissions(subagent), order)
|
||||
if (permission) {
|
||||
return { type: "permission", request: permission }
|
||||
}
|
||||
|
||||
const question = firstByOrder(data.questions, listSubagentQuestions(subagent), order)
|
||||
if (question) {
|
||||
return { type: "question", request: question }
|
||||
}
|
||||
|
||||
return { type: "prompt" }
|
||||
}
|
||||
|
||||
function composeFooter(input: {
|
||||
patch?: FooterPatch
|
||||
subagent?: FooterSubagentState
|
||||
current: FooterView
|
||||
previous: FooterView
|
||||
}) {
|
||||
let footer: FooterOutput | undefined
|
||||
|
||||
if (input.subagent) {
|
||||
footer = {
|
||||
...(footer ?? {}),
|
||||
subagent: input.subagent,
|
||||
}
|
||||
}
|
||||
|
||||
if (!sameView(input.previous, input.current)) {
|
||||
footer = {
|
||||
...(footer ?? {}),
|
||||
view: input.current,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.current.type !== "prompt") {
|
||||
footer = {
|
||||
...(footer ?? {}),
|
||||
patch: {
|
||||
...(input.patch ?? {}),
|
||||
status: blockerStatus(input.current),
|
||||
},
|
||||
}
|
||||
return footer
|
||||
}
|
||||
|
||||
if (input.patch) {
|
||||
footer = {
|
||||
...(footer ?? {}),
|
||||
patch: input.patch,
|
||||
}
|
||||
return footer
|
||||
}
|
||||
|
||||
if (input.previous.type !== "prompt") {
|
||||
footer = {
|
||||
...(footer ?? {}),
|
||||
patch: {
|
||||
status: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return footer
|
||||
}
|
||||
|
||||
function sameTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) {
|
||||
if (!a || !b) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
a.sessionID === b.sessionID &&
|
||||
a.partID === b.partID &&
|
||||
a.callID === b.callID &&
|
||||
a.label === b.label &&
|
||||
a.description === b.description &&
|
||||
a.status === b.status &&
|
||||
a.title === b.title &&
|
||||
a.toolCalls === b.toolCalls &&
|
||||
a.lastUpdatedAt === b.lastUpdatedAt
|
||||
)
|
||||
}
|
||||
|
||||
function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: FooterSubagentTab[]) {
|
||||
const before = new Map(prev.map((item) => [item.sessionID, item]))
|
||||
const after = new Map(next.map((item) => [item.sessionID, item]))
|
||||
|
||||
for (const [sessionID, tab] of after) {
|
||||
if (sameTab(before.get(sessionID), tab)) {
|
||||
continue
|
||||
}
|
||||
|
||||
trace?.write("subagent.tab", {
|
||||
sessionID,
|
||||
tab,
|
||||
})
|
||||
}
|
||||
|
||||
for (const sessionID of before.keys()) {
|
||||
if (after.has(sessionID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
trace?.write("subagent.tab", {
|
||||
sessionID,
|
||||
cleared: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Opens an SDK event subscription and returns a SessionTransport.
|
||||
//
|
||||
// The background `watch` loop consumes every SDK event, runs it through the
|
||||
@@ -191,10 +379,169 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
|
||||
let data = createSessionData()
|
||||
let subagent = createSubagentData()
|
||||
let wait: Wait | undefined
|
||||
let tick = 0
|
||||
let fault: unknown
|
||||
let closed = false
|
||||
let footerView: FooterView = { type: "prompt" }
|
||||
let blockerTick = 0
|
||||
let selectedSubagent: string | undefined
|
||||
const blockers = new Map<string, number>()
|
||||
|
||||
const currentSubagentState = () => {
|
||||
if (selectedSubagent && !subagent.tabs.has(selectedSubagent)) {
|
||||
selectedSubagent = undefined
|
||||
}
|
||||
|
||||
return snapshotSelectedSubagentData(subagent, selectedSubagent)
|
||||
}
|
||||
|
||||
const seedBlocker = (id: string) => {
|
||||
if (blockers.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
blockerTick += 1
|
||||
blockers.set(id, blockerTick)
|
||||
}
|
||||
|
||||
const trackBlocker = (event: Event) => {
|
||||
if (event.type !== "permission.asked" && event.type !== "question.asked") {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.properties.sessionID !== input.sessionID && !subagent.tabs.has(event.properties.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
seedBlocker(event.properties.id)
|
||||
}
|
||||
|
||||
const releaseBlocker = (event: Event) => {
|
||||
if (
|
||||
event.type !== "permission.replied" &&
|
||||
event.type !== "question.replied" &&
|
||||
event.type !== "question.rejected"
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
blockers.delete(event.properties.requestID)
|
||||
}
|
||||
|
||||
const syncFooter = (commits: StreamCommit[], patch?: FooterPatch, nextSubagent?: FooterSubagentState) => {
|
||||
const current = pickView(data, subagent, blockers)
|
||||
const footer = composeFooter({
|
||||
patch,
|
||||
subagent: nextSubagent,
|
||||
current,
|
||||
previous: footerView,
|
||||
})
|
||||
|
||||
if (commits.length === 0 && !footer) {
|
||||
footerView = current
|
||||
return
|
||||
}
|
||||
|
||||
input.trace?.write("reduce.output", {
|
||||
commits,
|
||||
footer: traceFooterOutput(footer),
|
||||
})
|
||||
writeSessionOutput(
|
||||
{
|
||||
footer: input.footer,
|
||||
trace: input.trace,
|
||||
},
|
||||
{
|
||||
commits,
|
||||
footer,
|
||||
},
|
||||
)
|
||||
footerView = current
|
||||
}
|
||||
|
||||
const bootstrap = async () => {
|
||||
const [messages, children, permissions, questions] = await Promise.all([
|
||||
input.sdk.session
|
||||
.messages({
|
||||
sessionID: input.sessionID,
|
||||
limit: SUBAGENT_BOOTSTRAP_LIMIT,
|
||||
})
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
input.sdk.session
|
||||
.children({
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
input.sdk.permission
|
||||
.list()
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
input.sdk.question
|
||||
.list()
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => []),
|
||||
])
|
||||
|
||||
bootstrapSessionData({
|
||||
data,
|
||||
messages,
|
||||
permissions: permissions.filter((item) => item.sessionID === input.sessionID),
|
||||
questions: questions.filter((item) => item.sessionID === input.sessionID),
|
||||
})
|
||||
bootstrapSubagentData({
|
||||
data: subagent,
|
||||
messages,
|
||||
children,
|
||||
permissions,
|
||||
questions,
|
||||
})
|
||||
|
||||
const callSessions = [
|
||||
...new Set(
|
||||
listSubagentPermissions(subagent)
|
||||
.filter((item) => item.tool && item.metadata?.input === undefined)
|
||||
.map((item) => item.sessionID),
|
||||
),
|
||||
]
|
||||
if (callSessions.length > 0) {
|
||||
await Promise.all(
|
||||
callSessions.map(async (sessionID) => {
|
||||
const messages = await input.sdk.session
|
||||
.messages({
|
||||
sessionID,
|
||||
limit: SUBAGENT_CALL_BOOTSTRAP_LIMIT,
|
||||
})
|
||||
.then((x) => x.data ?? [])
|
||||
.catch(() => [])
|
||||
|
||||
bootstrapSubagentCalls({
|
||||
data: subagent,
|
||||
sessionID,
|
||||
messages,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const request of [
|
||||
...data.permissions,
|
||||
...listSubagentPermissions(subagent),
|
||||
...data.questions,
|
||||
...listSubagentQuestions(subagent),
|
||||
].sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
seedBlocker(request.id)
|
||||
}
|
||||
|
||||
const snapshot = currentSubagentState()
|
||||
traceTabs(input.trace, [], snapshot.tabs)
|
||||
syncFooter([], undefined, snapshot)
|
||||
}
|
||||
|
||||
await bootstrap()
|
||||
|
||||
const idle = async () => {
|
||||
try {
|
||||
@@ -252,16 +599,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
const flush = (type: "turn.abort" | "turn.cancel") => {
|
||||
const commits: StreamCommit[] = []
|
||||
flushInterrupted(data, commits)
|
||||
writeSessionOutput(
|
||||
{
|
||||
footer: input.footer,
|
||||
trace: input.trace,
|
||||
},
|
||||
{
|
||||
data,
|
||||
commits,
|
||||
},
|
||||
)
|
||||
syncFooter(commits)
|
||||
input.trace?.write(type, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
@@ -276,6 +614,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
|
||||
const event = item as Event
|
||||
input.trace?.write("recv.event", event)
|
||||
trackBlocker(event)
|
||||
const prevTabs = event.type === "message.part.updated" ? listSubagentTabs(subagent) : undefined
|
||||
const next = reduceSessionData({
|
||||
data,
|
||||
event,
|
||||
@@ -285,20 +625,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
})
|
||||
data = next.data
|
||||
|
||||
if (next.commits.length > 0 || next.footer?.patch || next.footer?.view) {
|
||||
input.trace?.write("reduce.output", {
|
||||
commits: next.commits,
|
||||
footer: next.footer,
|
||||
})
|
||||
const subagentChanged = reduceSubagentData({
|
||||
data: subagent,
|
||||
event,
|
||||
sessionID: input.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits(),
|
||||
})
|
||||
if (subagentChanged && prevTabs) {
|
||||
traceTabs(input.trace, prevTabs, listSubagentTabs(subagent))
|
||||
}
|
||||
releaseBlocker(event)
|
||||
|
||||
writeSessionOutput(
|
||||
{
|
||||
footer: input.footer,
|
||||
trace: input.trace,
|
||||
},
|
||||
next,
|
||||
)
|
||||
syncFooter(next.commits, next.footer?.patch, subagentChanged ? currentSubagentState() : undefined)
|
||||
|
||||
touch(event)
|
||||
await mark(event)
|
||||
@@ -328,6 +667,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
throw new Error("prompt already running")
|
||||
}
|
||||
|
||||
const prevTabs = listSubagentTabs(subagent)
|
||||
if (clearFinishedSubagents(subagent)) {
|
||||
const snapshot = currentSubagentState()
|
||||
traceTabs(input.trace, prevTabs, snapshot.tabs)
|
||||
syncFooter([], undefined, snapshot)
|
||||
}
|
||||
|
||||
const item = defer(tick)
|
||||
wait = item
|
||||
data.announced = false
|
||||
@@ -425,6 +771,16 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
}
|
||||
|
||||
const selectSubagent = (sessionID: string | undefined): void => {
|
||||
const next = sessionID && subagent.tabs.has(sessionID) ? sessionID : undefined
|
||||
if (selectedSubagent === next) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedSubagent = next
|
||||
syncFooter([], undefined, currentSubagentState())
|
||||
}
|
||||
|
||||
const close = async () => {
|
||||
if (closed) {
|
||||
return
|
||||
@@ -439,6 +795,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
|
||||
return {
|
||||
runPromptTurn,
|
||||
selectSubagent,
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
// Thin bridge between the session-data reducer output and the footer API.
|
||||
// Thin bridge between reducer output and the footer API.
|
||||
//
|
||||
// The reducer produces StreamCommit[] and an optional FooterOutput (patch +
|
||||
// view change). This module forwards them to footer.append() and
|
||||
// The reducers produce StreamCommit[] and an optional FooterOutput (patch +
|
||||
// view + subagent state). This module forwards them to footer.append() and
|
||||
// footer.event() respectively, adding trace writes along the way. It also
|
||||
// defaults status updates to phase "running" if the caller didn't set a
|
||||
// phase -- a convenience so reducer code doesn't have to repeat that.
|
||||
import type { FooterApi, FooterPatch } from "./types"
|
||||
import type { SessionDataOutput } from "./session-data"
|
||||
import type { FooterApi, FooterOutput, FooterPatch, FooterSubagentState, StreamCommit } from "./types"
|
||||
|
||||
type Trace = {
|
||||
write(type: string, data?: unknown): void
|
||||
@@ -17,6 +16,11 @@ type OutputInput = {
|
||||
trace?: Trace
|
||||
}
|
||||
|
||||
type StreamOutput = {
|
||||
commits: StreamCommit[]
|
||||
footer?: FooterOutput
|
||||
}
|
||||
|
||||
// Default to "running" phase when a status string arrives without an explicit phase.
|
||||
function patch(next: FooterPatch): FooterPatch {
|
||||
if (typeof next.status === "string" && next.phase === undefined) {
|
||||
@@ -29,8 +33,112 @@ function patch(next: FooterPatch): FooterPatch {
|
||||
return next
|
||||
}
|
||||
|
||||
function summarize(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
if (value.length <= 160) {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
type: "string",
|
||||
length: value.length,
|
||||
preview: `${value.slice(0, 160)}...`,
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
type: "array",
|
||||
length: value.length,
|
||||
}
|
||||
}
|
||||
|
||||
if (!value || typeof value !== "object") {
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
type: "object",
|
||||
keys: Object.keys(value),
|
||||
}
|
||||
}
|
||||
|
||||
function traceCommit(commit: StreamCommit) {
|
||||
return {
|
||||
...commit,
|
||||
text: summarize(commit.text),
|
||||
textLength: commit.text.length,
|
||||
part: commit.part
|
||||
? {
|
||||
id: commit.part.id,
|
||||
sessionID: commit.part.sessionID,
|
||||
messageID: commit.part.messageID,
|
||||
callID: commit.part.callID,
|
||||
tool: commit.part.tool,
|
||||
state: {
|
||||
status: commit.part.state.status,
|
||||
title: "title" in commit.part.state ? summarize(commit.part.state.title) : undefined,
|
||||
error: "error" in commit.part.state ? summarize(commit.part.state.error) : undefined,
|
||||
time: "time" in commit.part.state ? summarize(commit.part.state.time) : undefined,
|
||||
input: summarize(commit.part.state.input),
|
||||
metadata: "metadata" in commit.part.state ? summarize(commit.part.state.metadata) : undefined,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function traceSubagentState(state: FooterSubagentState) {
|
||||
return {
|
||||
tabs: state.tabs,
|
||||
details: Object.fromEntries(
|
||||
Object.entries(state.details).map(([sessionID, detail]) => [
|
||||
sessionID,
|
||||
{
|
||||
sessionID,
|
||||
commits: detail.commits.map(traceCommit),
|
||||
},
|
||||
]),
|
||||
),
|
||||
permissions: state.permissions.map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
permission: item.permission,
|
||||
patterns: item.patterns,
|
||||
tool: item.tool,
|
||||
metadata: item.metadata
|
||||
? {
|
||||
keys: Object.keys(item.metadata),
|
||||
input: summarize(item.metadata.input),
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
questions: state.questions.map((item) => ({
|
||||
id: item.id,
|
||||
sessionID: item.sessionID,
|
||||
questions: item.questions.map((question) => ({
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.length,
|
||||
multiple: question.multiple,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function traceFooterOutput(footer?: FooterOutput) {
|
||||
if (!footer?.subagent) {
|
||||
return footer
|
||||
}
|
||||
|
||||
return {
|
||||
...footer,
|
||||
subagent: traceSubagentState(footer.subagent),
|
||||
}
|
||||
}
|
||||
|
||||
// Forwards reducer output to the footer: commits go to scrollback, patches update the status bar.
|
||||
export function writeSessionOutput(input: OutputInput, out: SessionDataOutput): void {
|
||||
export function writeSessionOutput(input: OutputInput, out: StreamOutput): void {
|
||||
for (const commit of out.commits) {
|
||||
input.trace?.write("ui.commit", commit)
|
||||
input.footer.append(commit)
|
||||
@@ -45,6 +153,14 @@ export function writeSessionOutput(input: OutputInput, out: SessionDataOutput):
|
||||
})
|
||||
}
|
||||
|
||||
if (out.footer?.subagent) {
|
||||
input.trace?.write("ui.subagent", traceSubagentState(out.footer.subagent))
|
||||
input.footer.event({
|
||||
type: "stream.subagent",
|
||||
state: out.footer.subagent,
|
||||
})
|
||||
}
|
||||
|
||||
if (!out.footer?.view) {
|
||||
return
|
||||
}
|
||||
|
||||
715
packages/opencode/src/cli/cmd/run/subagent-data.ts
Normal file
715
packages/opencode/src/cli/cmd/run/subagent-data.ts
Normal file
@@ -0,0 +1,715 @@
|
||||
import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import * as Locale from "../../../util/locale"
|
||||
import {
|
||||
bootstrapSessionData,
|
||||
createSessionData,
|
||||
formatError,
|
||||
reduceSessionData,
|
||||
type SessionData,
|
||||
} from "./session-data"
|
||||
import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
||||
|
||||
export const SUBAGENT_BOOTSTRAP_LIMIT = 200
|
||||
export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80
|
||||
|
||||
const SUBAGENT_COMMIT_LIMIT = 80
|
||||
const SUBAGENT_CALL_LIMIT = 32
|
||||
const SUBAGENT_ROLE_LIMIT = 32
|
||||
const SUBAGENT_ERROR_LIMIT = 16
|
||||
const SUBAGENT_ECHO_LIMIT = 8
|
||||
|
||||
type SessionMessage = {
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
key: string
|
||||
commit: StreamCommit
|
||||
}
|
||||
|
||||
type DetailState = {
|
||||
sessionID: string
|
||||
data: SessionData
|
||||
frames: Frame[]
|
||||
}
|
||||
|
||||
export type SubagentData = {
|
||||
tabs: Map<string, FooterSubagentTab>
|
||||
details: Map<string, DetailState>
|
||||
}
|
||||
|
||||
export type BootstrapSubagentInput = {
|
||||
data: SubagentData
|
||||
messages: SessionMessage[]
|
||||
children: Array<{ id: string; title?: string }>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
}
|
||||
|
||||
function createDetail(sessionID: string): DetailState {
|
||||
return {
|
||||
sessionID,
|
||||
data: createSessionData({
|
||||
includeUserText: true,
|
||||
}),
|
||||
frames: [],
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDetail(data: SubagentData, sessionID: string) {
|
||||
const current = data.details.get(sessionID)
|
||||
if (current) {
|
||||
return current
|
||||
}
|
||||
|
||||
const next = createDetail(sessionID)
|
||||
data.details.set(sessionID, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function sameTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab) {
|
||||
if (!a) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
a.sessionID === b.sessionID &&
|
||||
a.partID === b.partID &&
|
||||
a.callID === b.callID &&
|
||||
a.label === b.label &&
|
||||
a.description === b.description &&
|
||||
a.status === b.status &&
|
||||
a.title === b.title &&
|
||||
a.toolCalls === b.toolCalls &&
|
||||
a.lastUpdatedAt === b.lastUpdatedAt
|
||||
)
|
||||
}
|
||||
|
||||
function sameQueue<T extends { id: string }>(left: T[], right: T[]) {
|
||||
return (
|
||||
left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index])
|
||||
)
|
||||
}
|
||||
|
||||
function sameCommit(left: StreamCommit, right: StreamCommit) {
|
||||
return (
|
||||
left.kind === right.kind &&
|
||||
left.text === right.text &&
|
||||
left.phase === right.phase &&
|
||||
left.source === right.source &&
|
||||
left.messageID === right.messageID &&
|
||||
left.partID === right.partID &&
|
||||
left.tool === right.tool &&
|
||||
left.interrupted === right.interrupted &&
|
||||
left.toolState === right.toolState &&
|
||||
left.toolError === right.toolError
|
||||
)
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
if (typeof value !== "string") {
|
||||
return
|
||||
}
|
||||
|
||||
const next = value.trim()
|
||||
return next || undefined
|
||||
}
|
||||
|
||||
function num(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
function inputLabel(input: Record<string, unknown>) {
|
||||
const description = text(input.description)
|
||||
if (description) {
|
||||
return description
|
||||
}
|
||||
|
||||
const command = text(input.command)
|
||||
if (command) {
|
||||
return command
|
||||
}
|
||||
|
||||
const filePath = text(input.filePath) ?? text(input.filepath)
|
||||
if (filePath) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
const pattern = text(input.pattern)
|
||||
if (pattern) {
|
||||
return pattern
|
||||
}
|
||||
|
||||
const query = text(input.query)
|
||||
if (query) {
|
||||
return query
|
||||
}
|
||||
|
||||
const url = text(input.url)
|
||||
if (url) {
|
||||
return url
|
||||
}
|
||||
|
||||
const path = text(input.path)
|
||||
if (path) {
|
||||
return path
|
||||
}
|
||||
|
||||
const prompt = text(input.prompt)
|
||||
if (prompt) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
function stateTitle(part: ToolPart) {
|
||||
return text("title" in part.state ? part.state.title : undefined)
|
||||
}
|
||||
|
||||
function callKey(messageID: string | undefined, callID: string | undefined) {
|
||||
if (!messageID || !callID) {
|
||||
return
|
||||
}
|
||||
|
||||
return `${messageID}:${callID}`
|
||||
}
|
||||
|
||||
function recent<T>(input: Iterable<T>, limit: number) {
|
||||
const list = [...input]
|
||||
return list.slice(Math.max(0, list.length - limit))
|
||||
}
|
||||
|
||||
function copyMap<K, V>(source: Map<K, V>, keep: Set<K>) {
|
||||
const out = new Map<K, V>()
|
||||
for (const [key, value] of source) {
|
||||
if (!keep.has(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(key, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function compactToolPart(part: ToolPart): ToolPart {
|
||||
return {
|
||||
id: part.id,
|
||||
type: "tool",
|
||||
sessionID: part.sessionID,
|
||||
messageID: part.messageID,
|
||||
callID: part.callID,
|
||||
tool: part.tool,
|
||||
state: {
|
||||
status: part.state.status,
|
||||
input: part.state.input,
|
||||
metadata: "metadata" in part.state ? part.state.metadata : undefined,
|
||||
time: "time" in part.state ? part.state.time : undefined,
|
||||
title: "title" in part.state ? part.state.title : undefined,
|
||||
error: "error" in part.state ? part.state.error : undefined,
|
||||
},
|
||||
} as ToolPart
|
||||
}
|
||||
|
||||
function compactCommit(commit: StreamCommit): StreamCommit {
|
||||
if (!commit.part) {
|
||||
return commit
|
||||
}
|
||||
|
||||
return {
|
||||
...commit,
|
||||
part: compactToolPart(commit.part),
|
||||
}
|
||||
}
|
||||
|
||||
function stateUpdatedAt(part: ToolPart) {
|
||||
if (!("time" in part.state)) {
|
||||
return Date.now()
|
||||
}
|
||||
|
||||
const time = part.state.time
|
||||
if (!("end" in time)) {
|
||||
return time.start ?? Date.now()
|
||||
}
|
||||
|
||||
return time.end ?? time.start ?? Date.now()
|
||||
}
|
||||
|
||||
function metadata(part: ToolPart, key: string) {
|
||||
return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key]
|
||||
}
|
||||
|
||||
function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab {
|
||||
const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general")
|
||||
const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? ""
|
||||
const status = part.state.status === "error" ? "error" : part.state.status === "completed" ? "completed" : "running"
|
||||
|
||||
return {
|
||||
sessionID,
|
||||
partID: part.id,
|
||||
callID: part.callID,
|
||||
label,
|
||||
description,
|
||||
status,
|
||||
title: stateTitle(part),
|
||||
toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")),
|
||||
lastUpdatedAt: stateUpdatedAt(part),
|
||||
}
|
||||
}
|
||||
|
||||
function taskSessionID(part: ToolPart) {
|
||||
return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID"))
|
||||
}
|
||||
|
||||
function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set<string>) {
|
||||
if (part.tool !== "task") {
|
||||
return false
|
||||
}
|
||||
|
||||
const sessionID = taskSessionID(part)
|
||||
if (!sessionID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (children && children.size > 0 && !children.has(sessionID)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const next = taskTab(part, sessionID)
|
||||
if (sameTab(data.tabs.get(sessionID), next)) {
|
||||
ensureDetail(data, sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
data.tabs.set(sessionID, next)
|
||||
ensureDetail(data, sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
function frameKey(commit: StreamCommit) {
|
||||
if (commit.partID) {
|
||||
return `${commit.kind}:${commit.partID}:${commit.phase}`
|
||||
}
|
||||
|
||||
if (commit.messageID) {
|
||||
return `${commit.kind}:${commit.messageID}:${commit.phase}`
|
||||
}
|
||||
|
||||
return `${commit.kind}:${commit.phase}:${commit.text}`
|
||||
}
|
||||
|
||||
function limitFrames(detail: DetailState) {
|
||||
if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) {
|
||||
return
|
||||
}
|
||||
|
||||
detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT)
|
||||
}
|
||||
|
||||
function mergeLiveCommit(current: StreamCommit, next: StreamCommit) {
|
||||
if (current.phase !== "progress" || next.phase !== "progress") {
|
||||
if (sameCommit(current, next)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...current,
|
||||
...next,
|
||||
text: current.text + next.text,
|
||||
}
|
||||
|
||||
if (sameCommit(current, merged)) {
|
||||
return current
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function appendCommits(detail: DetailState, commits: StreamCommit[]) {
|
||||
let changed = false
|
||||
|
||||
for (const commit of commits.map(compactCommit)) {
|
||||
const key = frameKey(commit)
|
||||
const index = detail.frames.findIndex((item) => item.key === key)
|
||||
if (index === -1) {
|
||||
detail.frames.push({
|
||||
key,
|
||||
commit,
|
||||
})
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
|
||||
const next = mergeLiveCommit(detail.frames[index].commit, commit)
|
||||
if (sameCommit(detail.frames[index].commit, next)) {
|
||||
continue
|
||||
}
|
||||
|
||||
detail.frames[index] = {
|
||||
key,
|
||||
commit: next,
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
limitFrames(detail)
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function ensureBlockerTab(
|
||||
data: SubagentData,
|
||||
sessionID: string,
|
||||
title: string | undefined,
|
||||
kind: "permission" | "question",
|
||||
) {
|
||||
if (data.tabs.has(sessionID)) {
|
||||
ensureDetail(data, sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
data.tabs.set(sessionID, {
|
||||
sessionID,
|
||||
partID: `bootstrap:${sessionID}`,
|
||||
callID: `bootstrap:${sessionID}`,
|
||||
label: text(title) ?? Locale.titlecase(kind),
|
||||
description: kind === "permission" ? "Pending permission" : "Pending question",
|
||||
status: "running",
|
||||
lastUpdatedAt: Date.now(),
|
||||
})
|
||||
ensureDetail(data, sessionID)
|
||||
return true
|
||||
}
|
||||
|
||||
function compactCallMap(detail: DetailState) {
|
||||
const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT))
|
||||
|
||||
for (const request of detail.data.permissions) {
|
||||
const key = callKey(request.tool?.messageID, request.tool?.callID)
|
||||
if (key) {
|
||||
keep.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of detail.frames) {
|
||||
const key = callKey(item.commit.part?.messageID, item.commit.part?.callID)
|
||||
if (key) {
|
||||
keep.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return copyMap(detail.data.call, keep)
|
||||
}
|
||||
|
||||
function compactEchoMap(data: SessionData, messageIDs: Set<string>) {
|
||||
const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)])
|
||||
return copyMap(data.echo, keys)
|
||||
}
|
||||
|
||||
function compactIDs(detail: DetailState) {
|
||||
return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT))
|
||||
}
|
||||
|
||||
function compactDetail(detail: DetailState) {
|
||||
const next = createSessionData({
|
||||
includeUserText: true,
|
||||
})
|
||||
const activePartIDs = new Set(detail.data.part.keys())
|
||||
const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : [])))
|
||||
const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools])
|
||||
const messageIDs = new Set([
|
||||
...[...activePartIDs]
|
||||
.map((partID) => detail.data.msg.get(partID))
|
||||
.filter((item): item is string => typeof item === "string"),
|
||||
...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT),
|
||||
])
|
||||
|
||||
next.announced = detail.data.announced
|
||||
next.permissions = detail.data.permissions
|
||||
next.questions = detail.data.questions
|
||||
next.ids = compactIDs(detail)
|
||||
next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item)))
|
||||
next.call = compactCallMap(detail)
|
||||
next.role = copyMap(detail.data.role, messageIDs)
|
||||
next.msg = copyMap(detail.data.msg, activePartIDs)
|
||||
next.part = copyMap(detail.data.part, activePartIDs)
|
||||
next.text = copyMap(detail.data.text, activePartIDs)
|
||||
next.sent = copyMap(detail.data.sent, activePartIDs)
|
||||
next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item)))
|
||||
next.echo = compactEchoMap(detail.data, messageIDs)
|
||||
detail.data = next
|
||||
}
|
||||
|
||||
function applyChildEvent(input: {
|
||||
detail: DetailState
|
||||
event: Event
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}) {
|
||||
const beforePermissions = input.detail.data.permissions.slice()
|
||||
const beforeQuestions = input.detail.data.questions.slice()
|
||||
const out = reduceSessionData({
|
||||
data: input.detail.data,
|
||||
event: input.event,
|
||||
sessionID: input.detail.sessionID,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
})
|
||||
const changed = appendCommits(input.detail, out.commits)
|
||||
compactDetail(input.detail)
|
||||
|
||||
return (
|
||||
changed ||
|
||||
!sameQueue(beforePermissions, input.detail.data.permissions) ||
|
||||
!sameQueue(beforeQuestions, input.detail.data.questions)
|
||||
)
|
||||
}
|
||||
|
||||
function knownSession(data: SubagentData, sessionID: string) {
|
||||
return data.tabs.has(sessionID)
|
||||
}
|
||||
|
||||
export function listSubagentPermissions(data: SubagentData) {
|
||||
return [...data.details.values()].flatMap((detail) => detail.data.permissions)
|
||||
}
|
||||
|
||||
export function listSubagentQuestions(data: SubagentData) {
|
||||
return [...data.details.values()].flatMap((detail) => detail.data.questions)
|
||||
}
|
||||
|
||||
export function createSubagentData(): SubagentData {
|
||||
return {
|
||||
tabs: new Map(),
|
||||
details: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotDetail(detail: DetailState) {
|
||||
return {
|
||||
sessionID: detail.sessionID,
|
||||
commits: detail.frames.map((item) => item.commit),
|
||||
}
|
||||
}
|
||||
|
||||
export function listSubagentTabs(data: SubagentData) {
|
||||
return [...data.tabs.values()].sort((a, b) => {
|
||||
const active = Number(b.status === "running") - Number(a.status === "running")
|
||||
if (active !== 0) {
|
||||
return active
|
||||
}
|
||||
|
||||
return b.lastUpdatedAt - a.lastUpdatedAt
|
||||
})
|
||||
}
|
||||
|
||||
function snapshotQueues(data: SubagentData) {
|
||||
return {
|
||||
permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotSubagentData(data: SubagentData): FooterSubagentState {
|
||||
return {
|
||||
tabs: listSubagentTabs(data),
|
||||
details: Object.fromEntries(
|
||||
[...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)]),
|
||||
),
|
||||
...snapshotQueues(data),
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotSelectedSubagentData(
|
||||
data: SubagentData,
|
||||
selectedSessionID: string | undefined,
|
||||
): FooterSubagentState {
|
||||
const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined
|
||||
|
||||
return {
|
||||
tabs: listSubagentTabs(data),
|
||||
details: detail ? { [detail.sessionID]: snapshotDetail(detail) } : {},
|
||||
...snapshotQueues(data),
|
||||
}
|
||||
}
|
||||
|
||||
export function bootstrapSubagentData(input: BootstrapSubagentInput) {
|
||||
const child = new Map(input.children.map((item) => [item.id, item]))
|
||||
const children = new Set(child.keys())
|
||||
let changed = false
|
||||
|
||||
for (const message of input.messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") {
|
||||
continue
|
||||
}
|
||||
|
||||
changed = syncTaskTab(input.data, part, children) || changed
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of input.permissions) {
|
||||
if (!children.has(item.sessionID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed
|
||||
}
|
||||
|
||||
for (const item of input.questions) {
|
||||
if (!children.has(item.sessionID)) {
|
||||
continue
|
||||
}
|
||||
|
||||
changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed
|
||||
}
|
||||
|
||||
for (const sessionID of input.data.tabs.keys()) {
|
||||
const detail = ensureDetail(input.data, sessionID)
|
||||
const beforePermissions = detail.data.permissions.slice()
|
||||
const beforeQuestions = detail.data.questions.slice()
|
||||
|
||||
bootstrapSessionData({
|
||||
data: detail.data,
|
||||
messages: [],
|
||||
permissions: input.permissions
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
questions: input.questions
|
||||
.filter((item) => item.sessionID === sessionID)
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
})
|
||||
compactDetail(detail)
|
||||
|
||||
changed =
|
||||
!sameQueue(beforePermissions, detail.data.permissions) ||
|
||||
!sameQueue(beforeQuestions, detail.data.questions) ||
|
||||
changed
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
export function bootstrapSubagentCalls(input: { data: SubagentData; sessionID: string; messages: SessionMessage[] }) {
|
||||
if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const detail = ensureDetail(input.data, input.sessionID)
|
||||
const beforePermissions = detail.data.permissions.slice()
|
||||
const beforeQuestions = detail.data.questions.slice()
|
||||
const beforeCallCount = detail.data.call.size
|
||||
bootstrapSessionData({
|
||||
data: detail.data,
|
||||
messages: input.messages,
|
||||
permissions: detail.data.permissions,
|
||||
questions: detail.data.questions,
|
||||
})
|
||||
compactDetail(detail)
|
||||
|
||||
return (
|
||||
beforeCallCount !== detail.data.call.size ||
|
||||
!sameQueue(beforePermissions, detail.data.permissions) ||
|
||||
!sameQueue(beforeQuestions, detail.data.questions)
|
||||
)
|
||||
}
|
||||
|
||||
export function clearFinishedSubagents(data: SubagentData) {
|
||||
let changed = false
|
||||
|
||||
for (const [sessionID, tab] of [...data.tabs.entries()]) {
|
||||
if (tab.status === "running") {
|
||||
continue
|
||||
}
|
||||
|
||||
data.tabs.delete(sessionID)
|
||||
data.details.delete(sessionID)
|
||||
changed = true
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
export function reduceSubagentData(input: {
|
||||
data: SubagentData
|
||||
event: Event
|
||||
sessionID: string
|
||||
thinking: boolean
|
||||
limits: Record<string, number>
|
||||
}) {
|
||||
const event = input.event
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID === input.sessionID) {
|
||||
if (part.type !== "tool") {
|
||||
return false
|
||||
}
|
||||
|
||||
return syncTaskTab(input.data, part)
|
||||
}
|
||||
}
|
||||
|
||||
const sessionID =
|
||||
event.type === "message.updated" ||
|
||||
event.type === "message.part.delta" ||
|
||||
event.type === "permission.asked" ||
|
||||
event.type === "permission.replied" ||
|
||||
event.type === "question.asked" ||
|
||||
event.type === "question.replied" ||
|
||||
event.type === "question.rejected" ||
|
||||
event.type === "session.error" ||
|
||||
event.type === "session.status"
|
||||
? event.properties.sessionID
|
||||
: event.type === "message.part.updated"
|
||||
? event.properties.part.sessionID
|
||||
: undefined
|
||||
|
||||
if (!sessionID || !knownSession(input.data, sessionID)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const detail = ensureDetail(input.data, sessionID)
|
||||
if (event.type === "session.status") {
|
||||
if (event.properties.status.type !== "retry") {
|
||||
return false
|
||||
}
|
||||
|
||||
return appendCommits(detail, [
|
||||
{
|
||||
kind: "error",
|
||||
text: event.properties.status.message,
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: `retry:${event.properties.status.attempt}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
if (event.type === "session.error" && event.properties.error) {
|
||||
return appendCommits(detail, [
|
||||
{
|
||||
kind: "error",
|
||||
text: formatError(event.properties.error),
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
return applyChildEvent({
|
||||
detail,
|
||||
event,
|
||||
thinking: input.thinking,
|
||||
limits: input.limits,
|
||||
})
|
||||
}
|
||||
@@ -92,10 +92,37 @@ export type FooterView =
|
||||
| { type: "permission"; request: PermissionRequest }
|
||||
| { type: "question"; request: QuestionRequest }
|
||||
|
||||
export type FooterPromptRoute = { type: "composer" } | { type: "subagent"; sessionID: string }
|
||||
|
||||
export type FooterSubagentTab = {
|
||||
sessionID: string
|
||||
partID: string
|
||||
callID: string
|
||||
label: string
|
||||
description: string
|
||||
status: "running" | "completed" | "error"
|
||||
title?: string
|
||||
toolCalls?: number
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
|
||||
export type FooterSubagentDetail = {
|
||||
sessionID: string
|
||||
commits: StreamCommit[]
|
||||
}
|
||||
|
||||
export type FooterSubagentState = {
|
||||
tabs: FooterSubagentTab[]
|
||||
details: Record<string, FooterSubagentDetail>
|
||||
permissions: PermissionRequest[]
|
||||
questions: QuestionRequest[]
|
||||
}
|
||||
|
||||
// The reducer emits this alongside scrollback commits so the footer can update in the same frame.
|
||||
export type FooterOutput = {
|
||||
patch?: FooterPatch
|
||||
view?: FooterView
|
||||
subagent?: FooterSubagentState
|
||||
}
|
||||
|
||||
// Typed messages sent to RunFooter.event(). The prompt queue and stream
|
||||
@@ -137,6 +164,10 @@ export type FooterEvent =
|
||||
type: "stream.view"
|
||||
view: FooterView
|
||||
}
|
||||
| {
|
||||
type: "stream.subagent"
|
||||
state: FooterSubagentState
|
||||
}
|
||||
|
||||
export type PermissionReply = Parameters<OpencodeClient["permission"]["reply"]>[0]
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { JSX } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import "opentui-spinner/solid"
|
||||
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
|
||||
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
const { theme } = useTheme()
|
||||
@@ -14,7 +14,7 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
|
||||
return (
|
||||
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}>⋯ {props.children}</text>}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner frames={frames} interval={80} color={color()} />
|
||||
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
|
||||
<Show when={props.children}>
|
||||
<text fg={color()}>{props.children}</text>
|
||||
</Show>
|
||||
|
||||
6
packages/opencode/test/cli/run/footer.test.ts
Normal file
6
packages/opencode/test/cli/run/footer.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RunFooter } from "../../../src/cli/cmd/run/footer"
|
||||
|
||||
test("run footer class loads", () => {
|
||||
expect(typeof RunFooter).toBe("function")
|
||||
})
|
||||
6
packages/opencode/test/cli/run/footer.view.test.tsx
Normal file
6
packages/opencode/test/cli/run/footer.view.test.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { RunFooterView } from "../../../src/cli/cmd/run/footer.view"
|
||||
|
||||
test("run footer view loads", () => {
|
||||
expect(typeof RunFooterView).toBe("function")
|
||||
})
|
||||
@@ -37,7 +37,6 @@ describe("run stream bridge", () => {
|
||||
footer: out.api,
|
||||
},
|
||||
{
|
||||
data: {} as never,
|
||||
commits,
|
||||
},
|
||||
)
|
||||
@@ -53,7 +52,6 @@ describe("run stream bridge", () => {
|
||||
footer: out.api,
|
||||
},
|
||||
{
|
||||
data: {} as never,
|
||||
commits: [],
|
||||
footer: {
|
||||
patch: {
|
||||
@@ -82,7 +80,6 @@ describe("run stream bridge", () => {
|
||||
footer: out.api,
|
||||
},
|
||||
{
|
||||
data: {} as never,
|
||||
commits: [],
|
||||
footer: {
|
||||
view: {
|
||||
@@ -101,4 +98,67 @@ describe("run stream bridge", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("forwards subagent footer snapshots as stream.subagent events", () => {
|
||||
const out = footer()
|
||||
|
||||
writeSessionOutput(
|
||||
{
|
||||
footer: out.api,
|
||||
},
|
||||
{
|
||||
commits: [],
|
||||
footer: {
|
||||
subagent: {
|
||||
tabs: [
|
||||
{
|
||||
sessionID: "child-1",
|
||||
partID: "part-1",
|
||||
callID: "call-1",
|
||||
label: "Explore",
|
||||
description: "Scan reducer paths",
|
||||
status: "running",
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
"child-1": {
|
||||
sessionID: "child-1",
|
||||
commits: [],
|
||||
},
|
||||
},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
expect(out.events).toEqual([
|
||||
{
|
||||
type: "stream.subagent",
|
||||
state: {
|
||||
tabs: [
|
||||
{
|
||||
sessionID: "child-1",
|
||||
partID: "part-1",
|
||||
callID: "call-1",
|
||||
label: "Explore",
|
||||
description: "Scan reducer paths",
|
||||
status: "running",
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
"child-1": {
|
||||
sessionID: "child-1",
|
||||
commits: [],
|
||||
},
|
||||
},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,6 +139,13 @@ function sdk(
|
||||
opt: {
|
||||
promptAsync?: (input: unknown, opt?: { signal?: AbortSignal }) => Promise<void>
|
||||
status?: () => Promise<{ data?: Record<string, { type: string }> }>
|
||||
messages?: (input: {
|
||||
sessionID: string
|
||||
limit?: number
|
||||
}) => Promise<{ data?: Array<{ info: unknown; parts: unknown[] }> }>
|
||||
children?: () => Promise<{ data?: Array<{ id: string }> }>
|
||||
permissions?: () => Promise<{ data?: unknown[] }>
|
||||
questions?: () => Promise<{ data?: unknown[] }>
|
||||
} = {},
|
||||
) {
|
||||
return {
|
||||
@@ -150,11 +157,235 @@ function sdk(
|
||||
session: {
|
||||
promptAsync: opt.promptAsync ?? (async () => {}),
|
||||
status: opt.status ?? (async () => ({ data: {} })),
|
||||
messages: opt.messages ?? (async () => ({ data: [] })),
|
||||
children: opt.children ?? (async () => ({ data: [] })),
|
||||
},
|
||||
permission: {
|
||||
list: opt.permissions ?? (async () => ({ data: [] })),
|
||||
},
|
||||
question: {
|
||||
list: opt.questions ?? (async () => ({ data: [] })),
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
}
|
||||
|
||||
describe("run stream transport", () => {
|
||||
test("bootstraps subagent tabs from parent task parts", async () => {
|
||||
const src = feed()
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk(src, {
|
||||
messages: async ({ sessionID }) => {
|
||||
if (sessionID !== "session-1") {
|
||||
throw new Error("unexpected child bootstrap")
|
||||
}
|
||||
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
id: "msg-1",
|
||||
role: "assistant",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "task-1",
|
||||
sessionID: "session-1",
|
||||
messageID: "msg-1",
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
description: "Explore run folder",
|
||||
subagent_type: "explore",
|
||||
},
|
||||
metadata: {
|
||||
sessionId: "child-1",
|
||||
},
|
||||
time: {
|
||||
start: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
children: async () => ({
|
||||
data: [{ id: "child-1" }],
|
||||
}),
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
expect(ui.events).toContainEqual({
|
||||
type: "stream.subagent",
|
||||
state: {
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
sessionID: "child-1",
|
||||
label: "Explore",
|
||||
description: "Explore run folder",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
details: {},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
|
||||
transport.selectSubagent("child-1")
|
||||
|
||||
expect(ui.events).toContainEqual({
|
||||
type: "stream.subagent",
|
||||
state: {
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
sessionID: "child-1",
|
||||
label: "Explore",
|
||||
description: "Explore run folder",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
details: {
|
||||
"child-1": {
|
||||
sessionID: "child-1",
|
||||
commits: [],
|
||||
},
|
||||
},
|
||||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("bootstraps resumed child permission input without recent parent task parts", async () => {
|
||||
const src = feed()
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk(src, {
|
||||
messages: async ({ sessionID }) => {
|
||||
if (sessionID === "session-1") {
|
||||
return { data: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
id: "msg-child-1",
|
||||
role: "assistant",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "edit-1",
|
||||
sessionID: "child-1",
|
||||
messageID: "msg-child-1",
|
||||
type: "tool",
|
||||
callID: "call-edit-1",
|
||||
tool: "edit",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
filePath: "src/run/subagent-data.ts",
|
||||
diff: "@@ -1 +1 @@",
|
||||
},
|
||||
time: {
|
||||
start: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
children: async () => ({
|
||||
data: [{ id: "child-1" }],
|
||||
}),
|
||||
permissions: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: "perm-1",
|
||||
sessionID: "child-1",
|
||||
permission: "edit",
|
||||
patterns: ["src/run/subagent-data.ts"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
tool: {
|
||||
messageID: "msg-child-1",
|
||||
callID: "call-edit-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
sessionID: "session-1",
|
||||
thinking: true,
|
||||
limits: () => ({}),
|
||||
footer: ui.api,
|
||||
})
|
||||
|
||||
try {
|
||||
expect(ui.events).toContainEqual({
|
||||
type: "stream.subagent",
|
||||
state: {
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
sessionID: "child-1",
|
||||
status: "running",
|
||||
}),
|
||||
],
|
||||
details: {},
|
||||
permissions: [
|
||||
expect.objectContaining({
|
||||
id: "perm-1",
|
||||
sessionID: "child-1",
|
||||
metadata: {
|
||||
input: {
|
||||
filePath: "src/run/subagent-data.ts",
|
||||
diff: "@@ -1 +1 @@",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
|
||||
expect(ui.events).toContainEqual({
|
||||
type: "stream.view",
|
||||
view: {
|
||||
type: "permission",
|
||||
request: expect.objectContaining({
|
||||
id: "perm-1",
|
||||
metadata: {
|
||||
input: {
|
||||
filePath: "src/run/subagent-data.ts",
|
||||
diff: "@@ -1 +1 @@",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
src.close()
|
||||
await transport.close()
|
||||
}
|
||||
})
|
||||
|
||||
test("respects the includeFiles flag when building prompt payloads", async () => {
|
||||
const src = feed()
|
||||
const ui = footer()
|
||||
@@ -491,6 +722,14 @@ describe("run stream transport", () => {
|
||||
session: {
|
||||
promptAsync: async () => {},
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async () => ({ data: [] }),
|
||||
children: async () => ({ data: [] }),
|
||||
},
|
||||
permission: {
|
||||
list: async () => ({ data: [] }),
|
||||
},
|
||||
question: {
|
||||
list: async () => ({ data: [] }),
|
||||
},
|
||||
} as unknown as OpencodeClient,
|
||||
sessionID: "session-1",
|
||||
|
||||
367
packages/opencode/test/cli/run/subagent-data.test.ts
Normal file
367
packages/opencode/test/cli/run/subagent-data.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeEntry } from "../../../src/cli/cmd/run/scrollback.format"
|
||||
import {
|
||||
bootstrapSubagentData,
|
||||
clearFinishedSubagents,
|
||||
createSubagentData,
|
||||
reduceSubagentData,
|
||||
snapshotSelectedSubagentData,
|
||||
snapshotSubagentData,
|
||||
} from "../../../src/cli/cmd/run/subagent-data"
|
||||
|
||||
function taskMessage(sessionID: string, status: "running" | "completed" | "error" = "completed") {
|
||||
return {
|
||||
info: {
|
||||
id: `msg-${sessionID}`,
|
||||
role: "assistant",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: `part-${sessionID}`,
|
||||
sessionID: "parent-1",
|
||||
messageID: `msg-${sessionID}`,
|
||||
type: "tool",
|
||||
callID: `call-${sessionID}`,
|
||||
tool: "task",
|
||||
state: {
|
||||
status,
|
||||
input: {
|
||||
description: "Scan reducer paths",
|
||||
subagent_type: "explore",
|
||||
},
|
||||
title: "Reducer touchpoints",
|
||||
metadata: {
|
||||
sessionId: sessionID,
|
||||
toolcalls: 4,
|
||||
},
|
||||
time: status === "running" ? { start: 1 } : { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const
|
||||
}
|
||||
|
||||
function question(id: string, sessionID: string) {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
questions: [
|
||||
{
|
||||
question: "Mode?",
|
||||
header: "Mode",
|
||||
options: [{ label: "Fast", description: "Quick pass" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe("run subagent data", () => {
|
||||
test("bootstraps tabs and child blockers from parent task parts", () => {
|
||||
const data = createSubagentData()
|
||||
|
||||
expect(
|
||||
bootstrapSubagentData({
|
||||
data,
|
||||
messages: [taskMessage("child-1") as never],
|
||||
children: [{ id: "child-1" }, { id: "child-2" }],
|
||||
permissions: [
|
||||
{
|
||||
id: "perm-1",
|
||||
sessionID: "child-1",
|
||||
permission: "read",
|
||||
patterns: ["src/**/*.ts"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
{
|
||||
id: "perm-2",
|
||||
sessionID: "other",
|
||||
permission: "read",
|
||||
patterns: ["src/**/*.ts"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
],
|
||||
questions: [question("question-1", "child-1"), question("question-2", "other")],
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
expect(snapshotSubagentData(data)).toEqual({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
sessionID: "child-1",
|
||||
label: "Explore",
|
||||
description: "Scan reducer paths",
|
||||
title: "Reducer touchpoints",
|
||||
status: "completed",
|
||||
toolCalls: 4,
|
||||
}),
|
||||
],
|
||||
details: {
|
||||
"child-1": {
|
||||
sessionID: "child-1",
|
||||
commits: [],
|
||||
},
|
||||
},
|
||||
permissions: [expect.objectContaining({ id: "perm-1", sessionID: "child-1" })],
|
||||
questions: [expect.objectContaining({ id: "question-1", sessionID: "child-1" })],
|
||||
})
|
||||
})
|
||||
|
||||
test("reduces child text tool and blocker events into footer detail state", () => {
|
||||
const data = createSubagentData()
|
||||
|
||||
bootstrapSubagentData({
|
||||
data,
|
||||
messages: [taskMessage("child-1", "running") as never],
|
||||
children: [{ id: "child-1" }],
|
||||
permissions: [],
|
||||
questions: [],
|
||||
})
|
||||
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
messageID: "msg-user-1",
|
||||
sessionID: "child-1",
|
||||
type: "text",
|
||||
text: "Inspect footer tabs",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "child-1",
|
||||
info: {
|
||||
id: "msg-user-1",
|
||||
role: "user",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "child-1",
|
||||
info: {
|
||||
id: "msg-assistant-1",
|
||||
role: "assistant",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "reason-1",
|
||||
messageID: "msg-assistant-1",
|
||||
sessionID: "child-1",
|
||||
type: "reasoning",
|
||||
text: "planning next steps",
|
||||
time: { start: 1 },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "tool-1",
|
||||
messageID: "msg-assistant-1",
|
||||
sessionID: "child-1",
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "bash",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
command: "git status --short",
|
||||
},
|
||||
time: { start: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "perm-1",
|
||||
sessionID: "child-1",
|
||||
permission: "bash",
|
||||
patterns: ["git status --short"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
tool: {
|
||||
messageID: "msg-assistant-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
const snapshot = snapshotSubagentData(data)
|
||||
|
||||
expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })])
|
||||
expect(snapshot.details["child-1"]).toEqual({
|
||||
sessionID: "child-1",
|
||||
commits: expect.any(Array),
|
||||
})
|
||||
expect(snapshot.details["child-1"]?.commits.map((item) => normalizeEntry(item))).toEqual([
|
||||
"› Inspect footer tabs",
|
||||
"Thinking: planning next steps",
|
||||
"# Shell\n$ git status --short",
|
||||
])
|
||||
expect(snapshot.permissions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "perm-1",
|
||||
metadata: {
|
||||
input: {
|
||||
command: "git status --short",
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
expect(snapshot.questions).toEqual([])
|
||||
})
|
||||
|
||||
test("continues live child text streams", () => {
|
||||
const data = createSubagentData()
|
||||
|
||||
bootstrapSubagentData({
|
||||
data,
|
||||
messages: [taskMessage("child-1", "running") as never],
|
||||
children: [{ id: "child-1" }],
|
||||
permissions: [],
|
||||
questions: [],
|
||||
})
|
||||
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "child-1",
|
||||
info: {
|
||||
id: "msg-assistant-1",
|
||||
role: "assistant",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
messageID: "msg-assistant-1",
|
||||
sessionID: "child-1",
|
||||
type: "text",
|
||||
text: "hello",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
sessionID: "child-1",
|
||||
messageID: "msg-assistant-1",
|
||||
partID: "txt-1",
|
||||
field: "text",
|
||||
delta: " world",
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
reduceSubagentData({
|
||||
data,
|
||||
sessionID: "parent-1",
|
||||
thinking: true,
|
||||
limits: {},
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
id: "txt-1",
|
||||
messageID: "msg-assistant-1",
|
||||
sessionID: "child-1",
|
||||
type: "text",
|
||||
text: "hello world",
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
})
|
||||
|
||||
expect(
|
||||
snapshotSelectedSubagentData(data, "child-1").details["child-1"]?.commits.map((item) => normalizeEntry(item)),
|
||||
).toEqual(["hello world"])
|
||||
})
|
||||
|
||||
test("clears finished tabs on the next parent prompt", () => {
|
||||
const data = createSubagentData()
|
||||
|
||||
bootstrapSubagentData({
|
||||
data,
|
||||
messages: [taskMessage("child-1", "completed") as never, taskMessage("child-2", "running") as never],
|
||||
children: [{ id: "child-1" }, { id: "child-2" }],
|
||||
permissions: [],
|
||||
questions: [],
|
||||
})
|
||||
|
||||
expect(clearFinishedSubagents(data)).toBe(true)
|
||||
expect(snapshotSubagentData(data).tabs).toEqual([
|
||||
expect.objectContaining({ sessionID: "child-2", status: "running" }),
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user