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
|
||||
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" } })
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user