Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogSubagentList } from "@tui/component/dialog-subagent-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
Expand Down Expand Up @@ -297,6 +298,19 @@ function App() {
dialog.clear()
},
},
{
title: "List subagents",
value: "session.subagents",
keybind: "session_subagents",
category: "Session",
disabled: route.data.type !== "session",
onSelect: () => {
const data = route.data
if (data.type === "session") {
dialog.replace(() => <DialogSubagentList sessionID={data.sessionID} />)
}
},
},
{
title: "Switch model",
value: "model.list",
Expand Down
144 changes: 144 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-subagent-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, onMount, Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useKV } from "../context/kv"
import { useSDK } from "../context/sdk"
import { Keybind } from "@/util/keybind"
import { DialogSessionRename } from "./dialog-session-rename"
import { buildSubagentOptions, findRootSession, getStatusIndicator, formatTimeAgo } from "../util/subagent-tree"
import "opentui-spinner/solid"

function getTreePrefix(depth: number): string {
if (depth === 0) return ""
return " ".repeat(depth - 1) + "└─ "
}

export function DialogSubagentList(props: { sessionID: string }) {
const dialog = useDialog()
const sync = useSync()
const { theme } = useTheme()
const route = useRoute()
const kv = useKV()
const sdk = useSDK()

const [toDelete, setToDelete] = createSignal<string>()
const deleteKeybind = "ctrl+d"

const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]

const rootSession = createMemo(() => findRootSession(sync.data.session, props.sessionID))

const options = createMemo(() => {
const root = rootSession()
const subagents = buildSubagentOptions(sync.data.session, sync.data.session_status ?? {}, props.sessionID)

const rootOption = root
? (() => {
const statusInfo = getStatusIndicator(sync.data.session_status?.[root.id])
const isRunning = statusInfo.status === "busy"
const isDeleting = toDelete() === root.id
const statusColor =
statusInfo.status === "busy" ? theme.primary : statusInfo.status === "retry" ? theme.warning : theme.success
return {
title: isDeleting ? `Press ${deleteKeybind} again to confirm` : root.title,
value: root.id,
description: "(main)",
bg: isDeleting ? theme.error : undefined,
gutter: isRunning ? (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
</Show>
) : (
<text fg={statusColor}>{statusInfo.icon}</text>
),
}
})()
: null

const subagentOptions = subagents.map((sub) => {
const isRunning = sub.status === "busy"
const isDeleting = toDelete() === sub.id
const statusColor = sub.status === "busy" ? theme.primary : sub.status === "retry" ? theme.warning : theme.success
const prefix = getTreePrefix(sub.depth + 1)

return {
title: isDeleting ? `Press ${deleteKeybind} again to confirm` : prefix + sub.title,
value: sub.id,
description: sub.timeAgo,
bg: isDeleting ? theme.error : undefined,
gutter: isRunning ? (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[⋯]</text>}>
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
</Show>
) : (
<text fg={statusColor}>{sub.statusIcon}</text>
),
}
})

return rootOption ? [rootOption, ...subagentOptions] : subagentOptions
})

const hasTree = createMemo(() => options().length > 0)

onMount(() => {
dialog.setSize("large")
})

return (
<Show
when={hasTree()}
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1} paddingBottom={1}>
<text fg={theme.text}>
<b>Subagent Tree</b>
</text>
<text fg={theme.textMuted}>No subagents in this session tree</text>
<text fg={theme.textMuted}>Subagents are created when the agent delegates tasks</text>
</box>
}
>
<DialogSelect
title={`Subagent Tree (root: ${rootSession()?.title ?? "Session"})`}
options={options()}
current={props.sessionID}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: Keybind.parse(deleteKeybind)[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
sdk.client.session.delete({
sessionID: option.value,
})
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
{
keybind: Keybind.parse("ctrl+r")[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
</Show>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,11 @@ export function Autocomplete(props: {
description: "toggle thinking visibility",
onSelect: () => command.trigger("session.toggle.thinking"),
},
{
display: "/subagents",
description: "list subagents in this session",
onSelect: () => command.trigger("session.subagents"),
},
)
if (sync.data.config.share !== "disabled") {
results.push({
Expand Down
131 changes: 131 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/subagent-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { Session, SessionStatus } from "@opencode-ai/sdk/v2"

export type SubagentOption = {
id: string
title: string
agentType: string
status: "idle" | "busy" | "retry"
statusIcon: string
timeAgo: string
depth: number
isCurrent: boolean
}

export function filterSubagents(sessions: Session[], parentID: string): Session[] {
return sessions.filter((s) => s.parentID === parentID)
}

export function getStatusIndicator(status: SessionStatus | undefined): {
icon: string
status: "idle" | "busy" | "retry"
} {
if (!status) return { icon: "●", status: "idle" }
if (status.type === "busy") return { icon: "◐", status: "busy" }
if (status.type === "retry") return { icon: "↻", status: "retry" }
return { icon: "●", status: "idle" }
}

export function formatTimeAgo(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)

if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
if (seconds > 10) return `${seconds}s ago`
return "now"
}

export function extractAgentType(session: Session): string {
const colonIndex = session.title.indexOf(":")
if (colonIndex > 0 && colonIndex < 20) {
return session.title.slice(0, colonIndex).trim()
}
return "subagent"
}

export function findRootSession(sessions: Session[], sessionID: string): Session | undefined {
const sessionMap = new Map(sessions.map((s) => [s.id, s]))
let current = sessionMap.get(sessionID)

while (current?.parentID) {
const parent = sessionMap.get(current.parentID)
if (!parent) break
current = parent
}

return current
}

function buildTreeRecursive(
sessions: Session[],
statuses: Record<string, SessionStatus>,
parentID: string,
depth: number,
currentSessionID: string,
): SubagentOption[] {
const children = filterSubagents(sessions, parentID)
const sorted = [...children].sort((a, b) => a.time.created - b.time.created)
const result: SubagentOption[] = []

for (const session of sorted) {
const statusInfo = getStatusIndicator(statuses[session.id])
result.push({
id: session.id,
title: session.title,
agentType: extractAgentType(session),
status: statusInfo.status,
statusIcon: statusInfo.icon,
timeAgo: formatTimeAgo(session.time.updated),
depth,
isCurrent: session.id === currentSessionID,
})
const childOptions = buildTreeRecursive(sessions, statuses, session.id, depth + 1, currentSessionID)
result.push(...childOptions)
}

return result
}

export function buildSubagentOptions(
sessions: Session[],
statuses: Record<string, SessionStatus>,
currentSessionID: string,
): SubagentOption[] {
const root = findRootSession(sessions, currentSessionID)
if (!root) return []

return buildTreeRecursive(sessions, statuses, root.id, 0, currentSessionID)
}

export function hasSubagents(sessions: Session[], parentID: string): boolean {
return sessions.some((s) => s.parentID === parentID)
}

export function countSubagents(sessions: Session[], parentID: string): number {
return sessions.filter((s) => s.parentID === parentID).length
}

export function countAllDescendants(sessions: Session[], parentID: string): number {
const direct = filterSubagents(sessions, parentID)
let count = direct.length
for (const child of direct) {
count += countAllDescendants(sessions, child.id)
}
return count
}

export function isPartOfTree(sessions: Session[], rootID: string, sessionID: string): boolean {
if (rootID === sessionID) return true
const sessionMap = new Map(sessions.map((s) => [s.id, s]))
let current = sessionMap.get(sessionID)

while (current?.parentID) {
if (current.parentID === rootID) return true
current = sessionMap.get(current.parentID)
}

return false
}
1 change: 1 addition & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ export namespace Config {
session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
session_subagents: z.string().optional().default("none").describe("List subagents in current session"),
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
Expand Down
Loading