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:
Simon Klee
2026-04-17 13:22:54 +02:00
parent 6cd8f005c6
commit bf15913b60
18 changed files with 2851 additions and 390 deletions

View File

@@ -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

View 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>
)
}

View File

@@ -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"
}

View File

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

View File

@@ -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 = () => {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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,
}
}

View File

@@ -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
}

View 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,
})
}

View File

@@ -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]

View File

@@ -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>

View 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")
})

View 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")
})

View File

@@ -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: [],
},
},
])
})
})

View File

@@ -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",

View 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" }),
])
})
})