Apply PR #30051: fix(tui): clarify inline subagent rows

This commit is contained in:
opencode-agent[bot]
2026-06-01 15:40:00 +00:00
5 changed files with 888 additions and 31 deletions
@@ -1800,6 +1800,7 @@ function InlineTool(props: {
complete: any
pending: string
spinner?: boolean
subagent?: boolean
children: JSX.Element
part: ToolPart
onClick?: () => void
@@ -1840,6 +1841,7 @@ function InlineTool(props: {
return (
<InlineToolRow
id={`tool-inline-${props.subagent ? "subagent-" : ""}${props.part.id}`}
icon={props.icon}
iconColor={props.iconColor}
color={fg()}
@@ -1851,6 +1853,7 @@ function InlineTool(props: {
complete={props.complete}
pending={props.pending}
spinner={props.spinner}
subagent={props.subagent}
separateAfter={(id) =>
sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false
}
@@ -1871,6 +1874,7 @@ function InlineTool(props: {
}
export function InlineToolRow(props: {
id?: string
icon: string
iconColor?: RGBA
color?: RGBA
@@ -1882,6 +1886,7 @@ export function InlineToolRow(props: {
complete: any
pending: string
spinner?: boolean
subagent?: boolean
children: JSX.Element
separateAfter?: (id: string | undefined) => boolean
onMouseOver?: () => void
@@ -1892,6 +1897,7 @@ export function InlineToolRow(props: {
return (
<box
id={props.id}
marginTop={margin()}
paddingLeft={3}
onMouseOver={props.onMouseOver}
@@ -1906,9 +1912,12 @@ export function InlineToolRow(props: {
const children = parent.getChildren()
const index = children.indexOf(el)
const previous = children[index - 1]
const previousInline = previous?.id.startsWith("tool-inline-") ?? false
const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false
setMargin(
previous?.id.startsWith("text-") ||
previous?.id.startsWith("tool-block-") ||
(previousInline && previousSubagent !== Boolean(props.subagent)) ||
props.separateAfter?.(previous?.id)
? 1
: 0,
@@ -2134,8 +2143,8 @@ function Read(props: ToolProps<typeof ReadTool>) {
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
</InlineTool>
<For each={loaded()}>
{(filepath) => (
<box paddingLeft={3}>
{(filepath, index) => (
<box id={`tool-inline-loaded-${props.part.id}-${index()}`} paddingLeft={3}>
<text paddingLeft={3} fg={theme.textMuted}>
Loaded {pathFormatter.format(filepath)}
</text>
@@ -2201,11 +2210,14 @@ function Task(props: ToolProps<typeof TaskTool>) {
tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
)
const isRunning = createMemo(() => props.part.state.status === "running")
const status = createMemo(() => sync.data.session_status[props.metadata.sessionId ?? ""])
const isRunning = createMemo(
() => props.part.state.status === "running" || (props.metadata.background === true && status() !== undefined),
)
const retry = createMemo(() => {
const status = sync.data.session_status[props.metadata.sessionId ?? ""]
if (status?.type !== "retry") return
return status
const value = status()
if (value?.type !== "retry") return
return value
})
const duration = createMemo(() => {
@@ -2233,12 +2245,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
} else content.push(`${tools().length} toolcalls`)
}
if (props.part.state.status === "completed") {
content.push(
props.metadata.background === true
? `${tools().length} toolcalls`
: `${tools().length} toolcalls · ${Locale.duration(duration())}`,
)
if (!isRunning() && props.part.state.status === "completed") {
content.push(`${tools().length} toolcalls · ${Locale.duration(duration())}`)
}
return content.join("\n")
@@ -2246,7 +2254,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
return (
<InlineTool
icon="│"
icon={props.part.state.status === "completed" ? "✓" : "│"}
subagent={true}
color={retry() ? theme.error : undefined}
spinner={isRunning()}
complete={props.input.description}
@@ -0,0 +1,753 @@
import type {
AssistantMessage,
GlobalEvent,
Message,
Part,
Session,
TextPart,
ToolPart,
ToolState,
UserMessage,
} from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
export const names = ["tools-mixed", "subagents"] as const
export type Name = (typeof names)[number]
type Transcript = Array<{ info: Message; parts: Part[] }>
type ScenarioEvent = GlobalEvent["payload"]
type Step = { after: number; event: ScenarioEvent }
export function createScenario(input: { name: Name; directory: string; fetch: typeof fetch; speed?: number }) {
const sessionID = `ses_tui_scenario_${input.name.replaceAll("-", "_")}`
const created = Date.now()
const scenario = {
session: session(sessionID, input.directory, input.name, created),
transcript: input.name === "subagents" ? subagents(sessionID, created) : toolsMixed(sessionID, created),
}
const childID = `${sessionID}_child`
const completedChildID = `${sessionID}_completed_child`
const child = {
session: { ...session(childID, input.directory, input.name, created), parentID: sessionID },
transcript: [
{
info: userMessage(childID, "msg_child_user", created),
parts: [text(childID, "msg_child_user", "part_child_user", "Inspect current delegated work.")],
},
{
info: assistantMessage(childID, "msg_child_assistant", "msg_child_user", created + 1),
parts: [
tool(
childID,
"msg_child_assistant",
"part_child_read",
"read",
running(
{ filePath: "src/cli/cmd/tui/routes/session/index.tsx" },
"src/cli/cmd/tui/routes/session/index.tsx",
),
),
],
},
],
}
const completedChild = {
session: { ...session(completedChildID, input.directory, input.name, created), parentID: sessionID },
transcript: [
{
info: userMessage(completedChildID, "msg_completed_child_user", created),
parts: [text(completedChildID, "msg_completed_child_user", "part_completed_child_user", "Review result.")],
},
{
info: assistantMessage(
completedChildID,
"msg_completed_child_assistant",
"msg_completed_child_user",
created + 1,
true,
),
parts: [
tool(
completedChildID,
"msg_completed_child_assistant",
"part_completed_child_read",
"read",
completed({ filePath: "src/cli/cmd/tui/routes/session/index.tsx" }, {}, "Read session renderer"),
),
],
},
],
}
const children = new Map([
[childID, child],
[completedChildID, completedChild],
])
const providers = [
{
id: "scenario",
name: "Scenario",
source: "custom",
env: [],
options: {},
models: {
scenario: {
id: "scenario",
providerID: "scenario",
api: { id: "scenario", url: "", npm: "" },
name: "Preview",
capabilities: {
temperature: false,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 1, output: 1 },
status: "active",
options: {},
headers: {},
release_date: "",
},
},
},
]
const handlers = new Set<(event: GlobalEvent) => void>()
const timers = new Set<Timer>()
const speed = input.speed && input.speed > 0 ? input.speed : 1
let count = 1
let eventID = 0
function emit(event: ScenarioEvent) {
eventID += 1
event.id = `scenario_event_${eventID}`
for (const handler of handlers) {
handler({ directory: "global", payload: event })
}
}
function play(steps: Step[]) {
for (const step of steps) {
const timer = setTimeout(() => {
timers.delete(timer)
emit(step.event)
}, step.after / speed)
timers.add(timer)
}
}
const fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(resource, init)
const pathname = new URL(request.url).pathname
const base = `/session/${sessionID}`
if (request.method === "GET" && pathname === base) return json(scenario.session)
if (request.method === "GET" && pathname === `${base}/message`) return json(scenario.transcript)
if (request.method === "GET" && (pathname === `${base}/todo` || pathname === `${base}/diff`)) return json([])
const child = children.get(pathname.split("/")[2] ?? "")
if (request.method === "GET" && child && pathname === `/session/${child.session.id}`) return json(child.session)
if (request.method === "GET" && child && pathname === `/session/${child.session.id}/message`)
return json(child.transcript)
if (
request.method === "GET" &&
child &&
(pathname === `/session/${child.session.id}/todo` || pathname === `/session/${child.session.id}/diff`)
)
return json([])
if (request.method === "POST" && pathname === `${base}/message`) {
const prompt = await promptText(request)
count += 1
const turn = interactiveTurn(sessionID, Date.now(), count, prompt)
play(turn.steps)
return json({ info: turn.assistant, parts: [] })
}
switch (pathname) {
case "/command":
case "/experimental/workspace":
case "/experimental/workspace/status":
case "/formatter":
case "/lsp":
return json([])
case "/agent":
return json([
{
name: "build",
description: "Synthetic state preview",
mode: "primary",
native: true,
permission: [],
model: { providerID: "scenario", modelID: "scenario" },
options: {},
},
])
case "/config":
case "/experimental/resource":
case "/mcp":
case "/provider/auth":
return json({})
case "/session/status":
return json({ [childID]: { type: "busy" } })
case "/config/providers":
return json({ providers, default: { scenario: "scenario" } })
case "/experimental/console":
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
case "/path":
return json({ home: "", state: "", config: "", worktree: input.directory, directory: input.directory })
case "/project/current":
return json({ id: "scenario" })
case "/provider":
return json({ all: [], default: { scenario: "scenario" }, connected: ["scenario"] })
case "/session":
return json([scenario.session])
case "/vcs":
return json({ branch: "scenario" })
}
throw new Error(`unexpected scenario request: ${request.method} ${pathname}`)
},
{ preconnect: input.fetch.preconnect },
)
const events: EventSource = {
subscribe: async (handler) => {
handlers.add(handler)
return () => {
handlers.delete(handler)
if (handlers.size > 0) return
for (const timer of timers) clearTimeout(timer)
timers.clear()
}
},
}
return { sessionID, fetch, events }
}
function session(sessionID: string, directory: string, name: Name, created: number): Session {
return {
id: sessionID,
slug: `tui-scenario-${name}`,
projectID: "global",
directory,
title: `TUI Scenario: ${name}`,
version: "scenario",
time: { created, updated: created },
}
}
function toolsMixed(sessionID: string, time: number): Transcript {
return turn(sessionID, time, 1, "Preview mixed tool rendering states.", [
text(
sessionID,
"msg_01_assistant",
"part_01_intro",
"Block tools should retain space while consecutive inline tools remain dense.",
),
tool(
sessionID,
"msg_01_assistant",
"part_02_shell",
"bash",
completed(
{ command: "git status --short", description: "Show changed files", workdir: "." },
{ output: " M packages/opencode/src/cli/cmd/tui/routes/session/index.tsx\n?? scenario-notes.md" },
"Show changed files",
),
),
tool(
sessionID,
"msg_01_assistant",
"part_03_grep",
"grep",
completed(
{ pattern: "InlineTool|BlockTool|renderBefore", path: "packages/opencode/src/cli/cmd/tui/routes/session" },
{ matches: 21 },
"Found matches",
),
),
tool(
sessionID,
"msg_01_assistant",
"part_04_glob",
"glob",
completed({ pattern: "packages/opencode/test/cli/tui/**/*snapshot*" }, { count: 6 }, "Found files"),
),
tool(
sessionID,
"msg_01_assistant",
"part_05_read",
"read",
completed(
{ filePath: "packages/opencode/src/cli/cmd/tui/routes/session/index.tsx", offset: 1780, limit: 130 },
{},
"Read session row renderer",
),
),
tool(
sessionID,
"msg_01_assistant",
"part_06_edit",
"edit",
completed(
{ filePath: "packages/opencode/src/cli/cmd/tui/routes/session/index.tsx" },
{
diff: '@@ -1896,1 +1896,2 @@\n- previous?.id.startsWith("text-")\n+ previous?.id.startsWith("text-") ||\n+ previous?.id.startsWith("tool-block-")',
diagnostics: {},
},
"Edit spacing boundary",
),
),
tool(
sessionID,
"msg_01_assistant",
"part_07_after_block",
"grep",
completed(
{ pattern: "tool-block-", path: "packages/opencode/src/cli/cmd/tui/routes/session/index.tsx" },
{ matches: 2 },
"Verify block marker",
),
),
tool(
sessionID,
"msg_01_assistant",
"part_08_error",
"read",
failed(
{ filePath: "packages/opencode/src/cli/cmd/tui/routes/session/missing.tsx" },
"Scenario read failed: file does not exist",
),
),
tool(
sessionID,
"msg_01_assistant",
"part_09_todo",
"todowrite",
completed(
{
todos: [
{ content: "Inspect spacing states", status: "completed", priority: "high" },
{ content: "Review subagent density", status: "in_progress", priority: "high" },
],
},
{
todos: [
{ content: "Inspect spacing states", status: "completed", priority: "high" },
{ content: "Review subagent density", status: "in_progress", priority: "high" },
],
},
"1 todo",
),
),
tool(
sessionID,
"msg_01_assistant",
"part_10_question",
"question",
completed(
{
questions: [
{
header: "Spacing",
question: "Should adjacent completed subagents retain a blank row?",
options: [{ label: "Yes", description: "Keep tasks visually distinct" }],
},
],
},
{ answers: [["Yes"]] },
"Asked question",
),
),
text(
sessionID,
"msg_01_assistant",
"part_11_tip",
'Type "agent tool weave" to inspect active and completed task groups, or "parallel subagents" to append another group.',
),
])
}
function subagents(sessionID: string, time: number): Transcript {
return turn(sessionID, time, 1, "Preview adjacent and parallel subagent rows.", [
text(
sessionID,
"msg_01_assistant",
"part_01_intro",
"These completed task rows are intentionally adjacent so task spacing changes are immediately visible.",
),
task(
sessionID,
"msg_01_assistant",
"part_02_task",
"Explore renderer history for task-row regressions",
false,
`${sessionID}_completed_child`,
),
task(
sessionID,
"msg_01_assistant",
"part_03_task",
"Compare spacing around long wrapped delegation summaries",
true,
`${sessionID}_completed_child`,
),
task(
sessionID,
"msg_01_assistant",
"part_04_task",
"Audit parallel subagent presentation and footer affordances",
true,
`${sessionID}_completed_child`,
),
text(
sessionID,
"msg_01_assistant",
"part_05_tip",
'Type "agent tool weave" to inspect active and completed task groups, or "parallel subagents" to append another group.',
),
])
}
function interactiveTurn(sessionID: string, time: number, index: number, prompt: string) {
const ids = {
user: `msg_${index.toString().padStart(2, "0")}_user`,
assistant: `msg_${index.toString().padStart(2, "0")}_assistant`,
}
const parts = responseParts(sessionID, ids.assistant, prompt)
const user = userMessage(sessionID, ids.user, time)
const assistant = assistantMessage(sessionID, ids.assistant, ids.user, time + 1)
return {
assistant,
steps: [
{ after: 0, event: messageUpdated(sessionID, user) },
{ after: 0, event: partUpdated(sessionID, text(sessionID, ids.user, `${ids.user}_text`, prompt)) },
{ after: 80, event: messageUpdated(sessionID, assistant) },
...parts.map((part, partIndex) => ({ after: 280 + partIndex * 260, event: partUpdated(sessionID, part) })),
{
after: 400 + parts.length * 260,
event: messageUpdated(sessionID, {
...assistant,
time: { ...assistant.time, completed: Date.now() },
finish: "stop",
}),
},
],
}
}
function responseParts(sessionID: string, messageID: string, prompt: string): Part[] {
if (/sandwich|weave/i.test(prompt)) {
return [
text(
sessionID,
messageID,
`${messageID}_01_text`,
"Active and completed delegated agent groups among inline tools:",
),
tool(
sessionID,
messageID,
`${messageID}_02_grep`,
"grep",
completed({ pattern: "InlineToolRow" }, { matches: 3 }, "Locate inline row renderer"),
),
tool(
sessionID,
messageID,
`${messageID}_03_task`,
"task",
running(
{ description: "Trace spacing while surrounding tools stay visible", subagent_type: "explore" },
"Delegating",
{ sessionId: `${sessionID}_child` },
),
),
task(
sessionID,
messageID,
`${messageID}_04_task`,
"Compare completed task separators after reads",
false,
`${sessionID}_completed_child`,
),
task(
sessionID,
messageID,
`${messageID}_05_task`,
"Confirm completed agent presentation",
true,
`${sessionID}_completed_child`,
),
tool(
sessionID,
messageID,
`${messageID}_06_glob`,
"glob",
completed({ pattern: "test/cli/tui/**/*.snap" }, { count: 4 }, "Locate snapshots"),
),
tool(
sessionID,
messageID,
`${messageID}_07_task`,
"task",
completed(
{ description: "Audit background task while its child is active", subagent_type: "explore" },
{ background: true, sessionId: `${sessionID}_child` },
"Audit background task while its child is active",
),
),
task(
sessionID,
messageID,
`${messageID}_08_task`,
"Confirm secondary completed agent presentation",
false,
`${sessionID}_completed_child`,
),
tool(
sessionID,
messageID,
`${messageID}_09_grep`,
"grep",
completed(
{ pattern: "spinner", path: "src/cli/cmd/tui/routes/session/index.tsx" },
{ matches: 3 },
"Verify spinner path",
),
),
]
}
if (/parallel|subagent|task/i.test(prompt)) {
return [
text(sessionID, messageID, `${messageID}_01_text`, "Three delegated investigations returned concurrently:"),
task(
sessionID,
messageID,
`${messageID}_02_task`,
"Review layout behavior in the session renderer",
false,
`${sessionID}_completed_child`,
),
task(
sessionID,
messageID,
`${messageID}_03_task`,
"Audit wrapping behavior for long task summaries",
true,
`${sessionID}_completed_child`,
),
task(
sessionID,
messageID,
`${messageID}_04_task`,
"Check task spacing against inline tool density",
true,
`${sessionID}_completed_child`,
),
]
}
if (/block|diff|edit/i.test(prompt)) {
return [
tool(
sessionID,
messageID,
`${messageID}_01_shell`,
"bash",
completed(
{ command: "git diff --stat", description: "Inspect diff" },
{ output: " session/index.tsx | 4 ++--" },
"Inspect diff",
),
),
tool(
sessionID,
messageID,
`${messageID}_02_read`,
"read",
completed({ filePath: "src/session/index.tsx" }, {}, "Read source"),
),
tool(
sessionID,
messageID,
`${messageID}_03_grep`,
"grep",
completed({ pattern: "InlineTool" }, { matches: 4 }, "Search source"),
),
]
}
if (/error|fail|denied/i.test(prompt)) {
return [
tool(sessionID, messageID, `${messageID}_01_error`, "read", failed({ filePath: "missing.ts" }, "File not found")),
tool(
sessionID,
messageID,
`${messageID}_02_read`,
"read",
completed({ filePath: "recovered.ts" }, {}, "Read fallback"),
),
]
}
if (/pending|running|spinner/i.test(prompt)) {
return [
tool(
sessionID,
messageID,
`${messageID}_01_read`,
"read",
running({ filePath: "src/session/index.tsx" }, "Reading source"),
),
tool(
sessionID,
messageID,
`${messageID}_02_task`,
"task",
running({ description: "Inspect running agent", subagent_type: "explore" }, "Delegating"),
),
]
}
return [
tool(
sessionID,
messageID,
`${messageID}_01_grep`,
"grep",
completed({ pattern: prompt }, { matches: 12 }, "Search preview"),
),
tool(
sessionID,
messageID,
`${messageID}_02_read`,
"read",
completed({ filePath: "src/cli/cmd/tui/routes/session/index.tsx" }, {}, "Read preview"),
),
]
}
function turn(sessionID: string, time: number, index: number, prompt: string, parts: Part[]): Transcript {
const userID = `msg_${index.toString().padStart(2, "0")}_user`
const assistantID = `msg_${index.toString().padStart(2, "0")}_assistant`
return [
{
info: userMessage(sessionID, userID, time),
parts: [text(sessionID, userID, `${userID}_text`, prompt)],
},
{
info: assistantMessage(sessionID, assistantID, userID, time + 1, true),
parts,
},
]
}
function userMessage(sessionID: string, id: string, time: number): UserMessage {
return {
id,
sessionID,
role: "user",
time: { created: time },
agent: "build",
model: { providerID: "scenario", modelID: "scenario" },
}
}
function assistantMessage(
sessionID: string,
id: string,
parentID: string,
time: number,
complete = false,
): AssistantMessage {
return {
id,
sessionID,
role: "assistant",
time: { created: time, ...(complete ? { completed: time + 500 } : {}) },
parentID,
modelID: "scenario",
providerID: "scenario",
mode: "build",
agent: "build",
path: { cwd: process.cwd(), root: process.cwd() },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...(complete ? { finish: "stop" } : {}),
}
}
function text(sessionID: string, messageID: string, id: string, value: string): TextPart {
return { id, sessionID, messageID, type: "text", text: value }
}
function task(
sessionID: string,
messageID: string,
id: string,
description: string,
background: boolean,
childSessionID?: string,
) {
return tool(
sessionID,
messageID,
id,
"task",
completed(
{ description, subagent_type: "explore" },
{ background, ...(childSessionID ? { sessionId: childSessionID } : {}) },
description,
),
)
}
function tool(sessionID: string, messageID: string, id: string, name: string, state: ToolState): ToolPart {
return { id, sessionID, messageID, type: "tool", callID: `call_${id}`, tool: name, state }
}
function completed(input: Record<string, unknown>, metadata: Record<string, unknown>, title: string): ToolState {
return {
status: "completed",
input,
output: title,
title,
metadata,
time: { start: Date.now(), end: Date.now() + 1 },
}
}
function running(
input: Record<string, unknown>,
title: string,
metadata: Record<string, unknown> = {},
): Extract<ToolState, { status: "running" }> {
return { status: "running", input, title, metadata, time: { start: Date.now() } }
}
function failed(input: Record<string, unknown>, error: string): ToolState {
return { status: "error", input, error, time: { start: Date.now(), end: Date.now() + 1 } }
}
function messageUpdated(sessionID: string, info: Message): ScenarioEvent {
return { id: "scenario", type: "message.updated", properties: { sessionID, info } }
}
function partUpdated(sessionID: string, part: Part): ScenarioEvent {
return { id: "scenario", type: "message.part.updated", properties: { sessionID, part, time: Date.now() } }
}
async function promptText(request: Request) {
const body: unknown = await request.json()
if (!body || typeof body !== "object" || !("parts" in body) || !Array.isArray(body.parts)) return "Preview tools"
return body.parts
.flatMap((part) => {
if (!part || typeof part !== "object" || !("type" in part) || part.type !== "text" || !("text" in part)) return []
return typeof part.text === "string" ? [part.text] : []
})
.join("\n")
}
function json(data: unknown) {
return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } })
}
+52 -18
View File
@@ -113,6 +113,16 @@ export const TuiThreadCommand = cmd({
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("scenario", {
type: "string",
choices: ["tools-mixed", "subagents"] as const,
describe: "render a synthetic TUI state preview",
})
.option("scenario-speed", {
type: "number",
default: 1,
describe: "playback speed for synthetic TUI responses",
}),
handler: async (args) => {
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
@@ -129,6 +139,27 @@ export const TuiThreadCommand = cmd({
return
}
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
if (args.scenario && external) {
UI.error("--scenario is only available with the internal transport")
process.exitCode = 1
return
}
if (args.scenario && (args.continue || args.session || args.fork)) {
UI.error("--scenario cannot be combined with --continue, --session, or --fork")
process.exitCode = 1
return
}
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const next = resolveThreadDirectory(args.project)
@@ -190,17 +221,7 @@ export const TuiThreadCommand = cmd({
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const discovered = external ? undefined : await ServerDiscovery.find()
const transport = external
? {
url: (await client.call("server", network)).url,
@@ -222,12 +243,24 @@ export const TuiThreadCommand = cmd({
events: createEventSource(client),
}
const scenario = args.scenario
? (await import("./scenario")).createScenario({
name: args.scenario,
directory: cwd,
fetch: transport.fetch!,
speed: args.scenarioSpeed,
})
: undefined
const sessionID = scenario?.sessionID ?? args.session
const fetch = scenario?.fetch ?? transport.fetch
const events = scenario?.events ?? transport.events
try {
await validateSession({
url: transport.url,
sessionID: args.session,
sessionID,
directory: cwd,
fetch: transport.fetch,
fetch,
headers: transport.headers,
})
} catch (error) {
@@ -236,9 +269,10 @@ export const TuiThreadCommand = cmd({
return
}
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
if (!args.scenario)
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
try {
const { createTuiRenderer, tui } = await import("./app")
@@ -253,12 +287,12 @@ export const TuiThreadCommand = cmd({
},
config,
directory: cwd,
fetch: transport.fetch,
fetch,
headers: transport.headers,
events: transport.events,
events,
args: {
continue: args.continue,
sessionID: args.session,
sessionID,
agent: args.agent,
model: args.model,
prompt,
@@ -52,3 +52,21 @@ exports[`TUI inline tool wrapping keeps separation after a padded user message 1
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
Path\\.data|data =" in packages/opencode/src (115 matches)"
`;
exports[`TUI inline tool wrapping separates a contiguous subagent group from inline tools 1`] = `
" ✱ Grep "Task" (2 matches)
⠙ Explore Task — Inspect active task spacing
✓ General Task — Confirm completed task spacing
↳ 1 toolcall · 501ms
→ Read src/cli/cmd/tui/routes/session/index.tsx"
`;
exports[`TUI inline tool wrapping separates a subagent group after an expanded read 1`] = `
" → Read src/cli/cmd/tui/routes/session/index.tsx
↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx
✓ Explore Task — Inspect active task spacing
↳ 1 toolcall · 501ms"
`;
@@ -86,6 +86,41 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" })
)
}
function SubagentGroupFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow id="tool-inline-before" icon="✱" complete={true} pending="">
Grep "Task" (2 matches)
</InlineToolRow>
<InlineToolRow id="tool-inline-subagent-one" icon="⠙" complete={true} pending="" subagent={true}>
Explore Task Inspect active task spacing
</InlineToolRow>
<InlineToolRow id="tool-inline-subagent-two" icon="✓" complete={true} pending="" subagent={true}>
{"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
<InlineToolRow id="tool-inline-after" icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
</box>
)
}
function LoadedReadBeforeSubagentFixture() {
return (
<box flexDirection="column" width={72}>
<InlineToolRow id="tool-inline-read" icon="→" complete={true} pending="">
Read src/cli/cmd/tui/routes/session/index.tsx
</InlineToolRow>
<box id="tool-inline-loaded-read-child" paddingLeft={3}>
<text paddingLeft={3}> Loaded src/cli/cmd/tui/routes/session/tools.tsx</text>
</box>
<InlineToolRow id="tool-inline-subagent-after-read" icon="✓" complete={true} pending="" subagent={true}>
{"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"}
</InlineToolRow>
</box>
)
}
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
testSetup = await testRender(component, options)
await testSetup.renderOnce()
@@ -116,4 +151,12 @@ describe("TUI inline tool wrapping", () => {
test("keeps separation after a padded user message", async () => {
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
})
test("separates a contiguous subagent group from inline tools", async () => {
expect(await renderFrame(() => <SubagentGroupFixture />, { width: 72, height: 10 })).toMatchSnapshot()
})
test("separates a subagent group after an expanded read", async () => {
expect(await renderFrame(() => <LoadedReadBeforeSubagentFixture />, { width: 72, height: 8 })).toMatchSnapshot()
})
})