mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(tui): show child models in subagent list
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { SessionInfo } from "@opencode/client"
|
||||
import { useRoute, useRouteData } from "../../../context/route"
|
||||
import { useData } from "../../../context/data"
|
||||
import { useClient } from "../../../context/client"
|
||||
import { useTheme } from "../../../context/theme"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { stringWidth } from "../../../util/string-width"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { useComposerTab } from "./context"
|
||||
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
|
||||
@@ -16,6 +18,7 @@ interface SubagentEntry {
|
||||
sessionID: string
|
||||
agent: string
|
||||
title: string
|
||||
modelLabel?: string
|
||||
status: string
|
||||
current: boolean
|
||||
prefix: string
|
||||
@@ -29,6 +32,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const navigate = useRoute().navigate
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const [store, setStore] = createStore({ selected: 0, active: true })
|
||||
@@ -41,6 +45,13 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
({ session, prefix }): SubagentEntry => {
|
||||
const title = withTimestampedFallback(session)
|
||||
const agentMatch = title.match(/@(\w+) subagent/)
|
||||
const model = session.model
|
||||
const modelName = model
|
||||
? (data.location.model
|
||||
.list(session.location)
|
||||
?.find((item) => item.providerID === model.providerID && item.id === model.id)?.name ??
|
||||
`${model.providerID}/${model.id}`)
|
||||
: undefined
|
||||
return {
|
||||
sessionID: session.id,
|
||||
agent: session.agent
|
||||
@@ -49,6 +60,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
? Locale.titlecase(agentMatch[1])
|
||||
: "Subagent",
|
||||
title: agentMatch ? title.replace(agentMatch[0], "").trim() || title : title,
|
||||
modelLabel: modelName ? `${modelName}${model?.variant ? ` (${model.variant})` : ""}` : undefined,
|
||||
status: data.session.status(session.id),
|
||||
current: session.id === route.sessionID,
|
||||
prefix,
|
||||
@@ -203,6 +215,20 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
if (entry.status === "running") return "Running"
|
||||
return ""
|
||||
})
|
||||
const modelLabel = createMemo(() =>
|
||||
entry.modelLabel
|
||||
? Locale.truncateWidth(entry.modelLabel, Math.max(8, Math.floor(dimensions().width / 3)))
|
||||
: undefined,
|
||||
)
|
||||
const titleLabel = createMemo(() =>
|
||||
Locale.truncateWidth(
|
||||
`${entry.prefix}${entry.agent}: ${entry.title}`,
|
||||
Math.max(
|
||||
1,
|
||||
dimensions().width - stringWidth(modelLabel() ?? "") - (status() ? status().length + 3 : 0) - 8,
|
||||
),
|
||||
),
|
||||
)
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
@@ -221,7 +247,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
navigate({ type: "session", sessionID: entry.sessionID })
|
||||
}}
|
||||
>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="row">
|
||||
<box flexGrow={1} flexShrink={1} minWidth={0} flexDirection="row">
|
||||
<text
|
||||
fg={
|
||||
active()
|
||||
@@ -232,16 +258,30 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
minWidth={0}
|
||||
>
|
||||
{entry.prefix}
|
||||
{entry.agent}: {entry.title}
|
||||
{titleLabel()}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={status()}>
|
||||
<text fg={active() ? theme.text.action.primary.focused : theme.text.muted} wrapMode="none">
|
||||
{status()}
|
||||
</text>
|
||||
</Show>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={modelLabel()}>
|
||||
{(label) => (
|
||||
<text fg={active() ? theme.text.action.primary.focused : theme.text.muted} wrapMode="none">
|
||||
{label()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={status()}>
|
||||
<Show when={modelLabel()}>
|
||||
<text fg={active() ? theme.text.action.primary.focused : theme.text.muted}>·</text>
|
||||
</Show>
|
||||
<text fg={active() ? theme.text.action.primary.focused : theme.text.muted} wrapMode="none">
|
||||
{status()}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -18,9 +18,14 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
const sessions = {
|
||||
parent: session("parent", "Parent"),
|
||||
"child-a": session("child-a", "First", "parent"),
|
||||
"child-b": session("child-b", "Second", "parent"),
|
||||
parent: { ...session("parent", "Parent"), model: { providerID: "anthropic", id: "sonnet" } },
|
||||
"child-a": { ...session("child-a", "First", "parent"), model: { providerID: "anthropic", id: "sonnet" } },
|
||||
"child-b": {
|
||||
...session("child-b", "Second task with an unusually long title", "parent"),
|
||||
model: { providerID: "openai", id: "codex", variant: "high" },
|
||||
},
|
||||
"child-c": { ...session("child-c", "Third", "parent"), model: { providerID: "custom", id: "reviewer" } },
|
||||
"child-d": session("child-d", "Fourth", "parent"),
|
||||
}
|
||||
|
||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev"), shell("sh-c", "python3 - <<'PY'\nimport json")]
|
||||
@@ -29,6 +34,7 @@ async function renderComposer(
|
||||
defaultTab: "subagents" | "shell",
|
||||
keybinds: Partial<TuiKeybind.Keybinds>,
|
||||
focusedTextarea = false,
|
||||
width = 100,
|
||||
) {
|
||||
const events = createEventStream()
|
||||
const interrupted: string[] = []
|
||||
@@ -41,6 +47,14 @@ async function renderComposer(
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({
|
||||
location: { directory },
|
||||
data: [
|
||||
{ providerID: "anthropic", id: "sonnet", name: "Claude Sonnet" },
|
||||
{ providerID: "openai", id: "codex", name: "GPT Codex" },
|
||||
],
|
||||
})
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
if (sessionID && sessionID in sessions) return json({ data: sessions[sessionID as keyof typeof sessions] })
|
||||
const interruptID = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/)?.[1]
|
||||
@@ -78,6 +92,9 @@ async function renderComposer(
|
||||
data.session.sync("parent"),
|
||||
data.session.sync("child-a"),
|
||||
data.session.sync("child-b"),
|
||||
data.session.sync("child-c"),
|
||||
data.session.sync("child-d"),
|
||||
data.location.model.sync(),
|
||||
data.shell.sync(),
|
||||
])
|
||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||
@@ -125,7 +142,7 @@ async function renderComposer(
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 20, kittyKeyboard: true },
|
||||
{ width, height: 20, kittyKeyboard: true },
|
||||
)
|
||||
await ready.promise
|
||||
await app.renderOnce()
|
||||
@@ -165,6 +182,34 @@ test("disabled subagent bindings have no component fallbacks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("subagent list shows each child's model even when it matches the parent", async () => {
|
||||
const composer = await renderComposer("subagents", {})
|
||||
try {
|
||||
const frame = composer.app.captureCharFrame()
|
||||
expect(frame).toMatch(/Build: First\s+Claude Sonnet · Running/)
|
||||
expect(frame).toMatch(/Build: Second task with an unusually long title\s+GPT Codex \(high\) · Running/)
|
||||
|
||||
composer.dispatch("composer.subagent.toggle-activity")
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toMatch(/Build: Third\s+custom\/reviewer/)
|
||||
expect(composer.app.captureCharFrame()).toMatch(/Build: Fourth\s*\n/)
|
||||
expect(composer.app.captureCharFrame()).not.toContain("Model unavailable")
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("narrow subagent list keeps the model and status on one row", async () => {
|
||||
const composer = await renderComposer("subagents", {}, false, 48)
|
||||
try {
|
||||
const rows = composer.app.captureCharFrame().split("\n")
|
||||
expect(rows.find((row) => row.includes("First"))).toContain("Claude Sonnet · Running")
|
||||
expect(rows.find((row) => row.includes("Second"))).toContain("GPT Codex (high) · Running")
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("disabled shell bindings have no component fallbacks", async () => {
|
||||
const composer = await renderComposer("shell", {
|
||||
"composer.shell.up": "none",
|
||||
|
||||
Reference in New Issue
Block a user