mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 10:45:33 -04:00
feat(tui): add child agent picker
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user