Compare commits

...
Author SHA1 Message Date
rekram1-node 6d810a8843 feat(tui): show child models in subagent list 2026-09-24 22:14:15 +00:00
2 changed files with 97 additions and 12 deletions
@@ -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",