mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-02 06:16:48 +02:00
Apply PR #30051: fix(tui): clarify inline subagent rows
This commit is contained in:
@@ -1800,6 +1800,7 @@ function InlineTool(props: {
|
|||||||
complete: any
|
complete: any
|
||||||
pending: string
|
pending: string
|
||||||
spinner?: boolean
|
spinner?: boolean
|
||||||
|
subagent?: boolean
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
part: ToolPart
|
part: ToolPart
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
@@ -1840,6 +1841,7 @@ function InlineTool(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InlineToolRow
|
<InlineToolRow
|
||||||
|
id={`tool-inline-${props.subagent ? "subagent-" : ""}${props.part.id}`}
|
||||||
icon={props.icon}
|
icon={props.icon}
|
||||||
iconColor={props.iconColor}
|
iconColor={props.iconColor}
|
||||||
color={fg()}
|
color={fg()}
|
||||||
@@ -1851,6 +1853,7 @@ function InlineTool(props: {
|
|||||||
complete={props.complete}
|
complete={props.complete}
|
||||||
pending={props.pending}
|
pending={props.pending}
|
||||||
spinner={props.spinner}
|
spinner={props.spinner}
|
||||||
|
subagent={props.subagent}
|
||||||
separateAfter={(id) =>
|
separateAfter={(id) =>
|
||||||
sync.data.message[ctx.sessionID]?.some((message) => message.role === "user" && message.id === id) ?? false
|
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: {
|
export function InlineToolRow(props: {
|
||||||
|
id?: string
|
||||||
icon: string
|
icon: string
|
||||||
iconColor?: RGBA
|
iconColor?: RGBA
|
||||||
color?: RGBA
|
color?: RGBA
|
||||||
@@ -1882,6 +1886,7 @@ export function InlineToolRow(props: {
|
|||||||
complete: any
|
complete: any
|
||||||
pending: string
|
pending: string
|
||||||
spinner?: boolean
|
spinner?: boolean
|
||||||
|
subagent?: boolean
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
separateAfter?: (id: string | undefined) => boolean
|
separateAfter?: (id: string | undefined) => boolean
|
||||||
onMouseOver?: () => void
|
onMouseOver?: () => void
|
||||||
@@ -1892,6 +1897,7 @@ export function InlineToolRow(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
id={props.id}
|
||||||
marginTop={margin()}
|
marginTop={margin()}
|
||||||
paddingLeft={3}
|
paddingLeft={3}
|
||||||
onMouseOver={props.onMouseOver}
|
onMouseOver={props.onMouseOver}
|
||||||
@@ -1906,9 +1912,12 @@ export function InlineToolRow(props: {
|
|||||||
const children = parent.getChildren()
|
const children = parent.getChildren()
|
||||||
const index = children.indexOf(el)
|
const index = children.indexOf(el)
|
||||||
const previous = children[index - 1]
|
const previous = children[index - 1]
|
||||||
|
const previousInline = previous?.id.startsWith("tool-inline-") ?? false
|
||||||
|
const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false
|
||||||
setMargin(
|
setMargin(
|
||||||
previous?.id.startsWith("text-") ||
|
previous?.id.startsWith("text-") ||
|
||||||
previous?.id.startsWith("tool-block-") ||
|
previous?.id.startsWith("tool-block-") ||
|
||||||
|
(previousInline && previousSubagent !== Boolean(props.subagent)) ||
|
||||||
props.separateAfter?.(previous?.id)
|
props.separateAfter?.(previous?.id)
|
||||||
? 1
|
? 1
|
||||||
: 0,
|
: 0,
|
||||||
@@ -2134,8 +2143,8 @@ function Read(props: ToolProps<typeof ReadTool>) {
|
|||||||
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
|
Read {pathFormatter.format(props.input.filePath)} {input(props.input, ["filePath"])}
|
||||||
</InlineTool>
|
</InlineTool>
|
||||||
<For each={loaded()}>
|
<For each={loaded()}>
|
||||||
{(filepath) => (
|
{(filepath, index) => (
|
||||||
<box paddingLeft={3}>
|
<box id={`tool-inline-loaded-${props.part.id}-${index()}`} paddingLeft={3}>
|
||||||
<text paddingLeft={3} fg={theme.textMuted}>
|
<text paddingLeft={3} fg={theme.textMuted}>
|
||||||
↳ Loaded {pathFormatter.format(filepath)}
|
↳ Loaded {pathFormatter.format(filepath)}
|
||||||
</text>
|
</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),
|
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 retry = createMemo(() => {
|
||||||
const status = sync.data.session_status[props.metadata.sessionId ?? ""]
|
const value = status()
|
||||||
if (status?.type !== "retry") return
|
if (value?.type !== "retry") return
|
||||||
return status
|
return value
|
||||||
})
|
})
|
||||||
|
|
||||||
const duration = createMemo(() => {
|
const duration = createMemo(() => {
|
||||||
@@ -2233,12 +2245,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||||||
} else content.push(`↳ ${tools().length} toolcalls`)
|
} else content.push(`↳ ${tools().length} toolcalls`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.part.state.status === "completed") {
|
if (!isRunning() && props.part.state.status === "completed") {
|
||||||
content.push(
|
content.push(`↳ ${tools().length} toolcalls · ${Locale.duration(duration())}`)
|
||||||
props.metadata.background === true
|
|
||||||
? `└ ${tools().length} toolcalls`
|
|
||||||
: `└ ${tools().length} toolcalls · ${Locale.duration(duration())}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return content.join("\n")
|
return content.join("\n")
|
||||||
@@ -2246,7 +2254,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<InlineTool
|
<InlineTool
|
||||||
icon="│"
|
icon={props.part.state.status === "completed" ? "✓" : "│"}
|
||||||
|
subagent={true}
|
||||||
color={retry() ? theme.error : undefined}
|
color={retry() ? theme.error : undefined}
|
||||||
spinner={isRunning()}
|
spinner={isRunning()}
|
||||||
complete={props.input.description}
|
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" } })
|
||||||
|
}
|
||||||
@@ -113,6 +113,16 @@ export const TuiThreadCommand = cmd({
|
|||||||
.option("agent", {
|
.option("agent", {
|
||||||
type: "string",
|
type: "string",
|
||||||
describe: "agent to use",
|
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) => {
|
handler: async (args) => {
|
||||||
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
|
||||||
@@ -129,6 +139,27 @@ export const TuiThreadCommand = cmd({
|
|||||||
return
|
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
|
// Resolve relative --project paths from PWD, then use the real cwd after
|
||||||
// chdir so the thread and worker share the same directory key.
|
// chdir so the thread and worker share the same directory key.
|
||||||
const next = resolveThreadDirectory(args.project)
|
const next = resolveThreadDirectory(args.project)
|
||||||
@@ -190,17 +221,7 @@ export const TuiThreadCommand = cmd({
|
|||||||
|
|
||||||
const prompt = await input(args.prompt)
|
const prompt = await input(args.prompt)
|
||||||
const config = await TuiConfig.get()
|
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 discovered = external ? undefined : await ServerDiscovery.find()
|
||||||
|
|
||||||
const transport = external
|
const transport = external
|
||||||
? {
|
? {
|
||||||
url: (await client.call("server", network)).url,
|
url: (await client.call("server", network)).url,
|
||||||
@@ -222,12 +243,24 @@ export const TuiThreadCommand = cmd({
|
|||||||
events: createEventSource(client),
|
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 {
|
try {
|
||||||
await validateSession({
|
await validateSession({
|
||||||
url: transport.url,
|
url: transport.url,
|
||||||
sessionID: args.session,
|
sessionID,
|
||||||
directory: cwd,
|
directory: cwd,
|
||||||
fetch: transport.fetch,
|
fetch,
|
||||||
headers: transport.headers,
|
headers: transport.headers,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -236,9 +269,10 @@ export const TuiThreadCommand = cmd({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
if (!args.scenario)
|
||||||
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
|
setTimeout(() => {
|
||||||
}, 1000).unref?.()
|
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
|
||||||
|
}, 1000).unref?.()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { createTuiRenderer, tui } = await import("./app")
|
const { createTuiRenderer, tui } = await import("./app")
|
||||||
@@ -253,12 +287,12 @@ export const TuiThreadCommand = cmd({
|
|||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
directory: cwd,
|
directory: cwd,
|
||||||
fetch: transport.fetch,
|
fetch,
|
||||||
headers: transport.headers,
|
headers: transport.headers,
|
||||||
events: transport.events,
|
events,
|
||||||
args: {
|
args: {
|
||||||
continue: args.continue,
|
continue: args.continue,
|
||||||
sessionID: args.session,
|
sessionID,
|
||||||
agent: args.agent,
|
agent: args.agent,
|
||||||
model: args.model,
|
model: args.model,
|
||||||
prompt,
|
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\\.
|
✱ Grep "export const OPENCODE_DB|OPENCODE_DB|OPENCODE_DEV|Global\\.
|
||||||
Path\\.data|data =" in packages/opencode/src (115 matches)"
|
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 }) {
|
async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) {
|
||||||
testSetup = await testRender(component, options)
|
testSetup = await testRender(component, options)
|
||||||
await testSetup.renderOnce()
|
await testSetup.renderOnce()
|
||||||
@@ -116,4 +151,12 @@ describe("TUI inline tool wrapping", () => {
|
|||||||
test("keeps separation after a padded user message", async () => {
|
test("keeps separation after a padded user message", async () => {
|
||||||
expect(await renderFrame(() => <Fixture before="user" />, { width: 72, height: 14 })).toMatchSnapshot()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user