feat(tui): add child agent picker

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
Dax Raad
2026-06-27 02:19:09 +00:00
committed by 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
parent 573ab9c24b
commit b08e3ef8de
3 changed files with 171 additions and 7 deletions
+1 -3
View File
@@ -1099,9 +1099,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session />}
</Show>
<Session />
</Match>
</Switch>
{plugin()}
@@ -0,0 +1,114 @@
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
import { createSignal, For, onCleanup, onMount } from "solid-js"
import { useRenderer } from "@opentui/solid"
import { selectedForeground, useTheme } from "../../context/theme"
import { useBindings, useOpencodeModeStack } from "../../keymap"
import { SplitBorder } from "../../ui/border"
const mode = "child-session-picker"
export function ChildSessionPicker(props: {
sessions: SessionV2Info[]
currentID: string
child: boolean
status: (sessionID: string) => "idle" | "running"
onSelect: (sessionID: string) => void
onEscape: () => void
}) {
const renderer = useRenderer()
const modeStack = useOpencodeModeStack()
const { theme } = useTheme()
const [selected, setSelected] = createSignal(
Math.max(
0,
props.sessions.findIndex((item) => item.id === props.currentID),
),
)
const move = (direction: -1 | 1) =>
setSelected((selected() + direction + props.sessions.length) % props.sessions.length)
onMount(() => {
const popMode = modeStack.push(mode)
onCleanup(popMode)
})
useBindings(() => ({
mode,
bindings: [
{ key: "up", desc: "Previous child agent", group: "Child agents", cmd: () => move(-1) },
{ key: "down", desc: "Next child agent", group: "Child agents", cmd: () => move(1) },
{
key: "return",
desc: "Open child agent",
group: "Child agents",
cmd: () => props.onSelect(props.sessions[selected()].id),
},
{
key: "escape",
desc: props.child ? "Return to parent session" : "Close child agents",
group: "Child agents",
cmd: props.onEscape,
},
],
}))
return (
<box
flexShrink={0}
backgroundColor={theme.backgroundPanel}
border={["left"]}
borderColor={theme.accent}
customBorderChars={SplitBorder.customBorderChars}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
gap={1}
>
<text fg={theme.text}>
<b>Child agents</b>
</text>
<text fg={theme.textMuted}>Choose an agent to view its session</text>
<box>
<For each={props.sessions}>
{(session, index) => {
const active = () => selected() === index()
const running = () => props.status(session.id) === "running"
const foreground = () => (active() ? selectedForeground(theme, theme.accent) : theme.text)
return (
<box
flexDirection="row"
justifyContent="space-between"
paddingLeft={1}
paddingRight={1}
backgroundColor={active() ? theme.accent : theme.backgroundPanel}
onMouseOver={() => setSelected(index())}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onSelect(session.id)
}}
>
<box flexDirection="row" gap={1} minWidth={0}>
<text fg={active() ? foreground() : running() ? theme.success : theme.textMuted}>
{running() ? "●" : "○"}
</text>
<text fg={foreground()} wrapMode="none" flexShrink={1}>
<b>@{session.agent ?? "agent"}</b>{" "}
<span style={{ dim: !active() }}>{session.title.replace(/\s+\(@[^)]+ subagent\)$/, "")}</span>
</text>
</box>
<text fg={active() ? foreground() : running() ? theme.success : theme.textMuted}>
{running() ? "Running" : "Complete"}
</text>
</box>
)
}}
</For>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}> navigate · enter open</text>
<text fg={theme.textMuted}>{props.child ? "esc parent" : "esc close"}</text>
</box>
</box>
)
}
+56 -4
View File
@@ -69,6 +69,7 @@ import { OPENCODE_BASE_MODE, useBindings } from "../../keymap"
import { usePathFormatter } from "../../context/path-format"
import { LocationProvider } from "../../context/location"
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
import { ChildSessionPicker } from "./child-session-picker"
addDefaultParsers(parsers.parsers)
@@ -137,6 +138,10 @@ function use() {
}
export function Session() {
const route = useRouteData("session")
const [childPicker, setChildPicker] = createSignal(false)
function SessionView() {
const setEpilogue = useEpilogue()
const clipboard = useClipboard()
const writeExport = async (file: string, content: string) => {
@@ -175,8 +180,23 @@ export function Session() {
if (session()?.parentID) return []
return data.session.question.list(route.sessionID) ?? []
})
const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0)
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
const childSessions = createMemo(() => {
const parentID = session()?.parentID ?? session()?.id
if (!parentID) return []
return data.session
.list()
.filter((item) => item.parentID === parentID)
.toSorted((a, b) => {
const running =
Number(data.session.status(b.id) === "running") - Number(data.session.status(a.id) === "running")
return running || b.time.updated - a.time.updated
})
})
const childPickerVisible = createMemo(
() => (!!session()?.parentID || childPicker()) && !disabled() && childSessions().length > 0,
)
const visible = createMemo(() => !session()?.parentID && !childPickerVisible() && !disabled())
const pending = createMemo(() => {
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
@@ -726,11 +746,15 @@ export function Session() {
run: () => unavailable("Backgrounding subagents"),
},
{
title: "Go to child session",
title: "View child agents",
value: "session.child.first",
category: "Session",
hidden: true,
run: () => unavailable("Child session discovery"),
enabled: childSessions().length > 0 && !disabled(),
run: () => {
setChildPicker(true)
dialog.clear()
},
},
{
title: "Go to parent session",
@@ -863,7 +887,28 @@ export function Session() {
directory={session()?.location.directory}
/>
</Show>
<Show when={session()?.parentID}>
<Show when={childPickerVisible()}>
<ChildSessionPicker
sessions={childSessions()}
currentID={route.sessionID}
child={!!session()?.parentID}
status={(sessionID) => data.session.status(sessionID)}
onSelect={(sessionID) => {
if (sessionID !== route.sessionID) navigate({ type: "session", sessionID })
}}
onEscape={() => {
const parentID = session()?.parentID
if (parentID) {
setChildPicker(true)
navigate({ type: "session", sessionID: parentID })
return
}
setChildPicker(false)
queueMicrotask(() => prompt?.focus())
}}
/>
</Show>
<Show when={session()?.parentID && !childPickerVisible()}>
<SubagentFooter />
</Show>
<Show when={visible()}>
@@ -916,6 +961,13 @@ export function Session() {
</context.Provider>
</LocationProvider>
)
}
return (
<Show when={route.sessionID || undefined} keyed>
{(_sessionID: string) => <SessionView />}
</Show>
)
}
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessage | undefined }) {