diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index 40a4c93f70c..0eb23ad095c 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -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 ( - + - {entry.prefix} - {entry.agent}: {entry.title} + {titleLabel()} - - - {status()} - - + + + {(label) => ( + + {label()} + + )} + + + + · + + + {status()} + + + ) }} diff --git a/packages/tui/test/cli/tui/composer-keymap.test.tsx b/packages/tui/test/cli/tui/composer-keymap.test.tsx index 9be0a5d0a3d..61353d4a18d 100644 --- a/packages/tui/test/cli/tui/composer-keymap.test.tsx +++ b/packages/tui/test/cli/tui/composer-keymap.test.tsx @@ -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, 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( ), - { 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",