feat(tui): add child agent picker
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
parent
573ab9c24b
commit
b08e3ef8de
3 changed files with 171 additions and 7 deletions
|
|
@ -1099,9 +1099,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||||
<Home />
|
<Home />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={route.data.type === "session"}>
|
<Match when={route.data.type === "session"}>
|
||||||
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
|
<Session />
|
||||||
{(_) => <Session />}
|
|
||||||
</Show>
|
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
{plugin()}
|
{plugin()}
|
||||||
|
|
|
||||||
114
packages/tui/src/routes/session/child-session-picker.tsx
Normal file
114
packages/tui/src/routes/session/child-session-picker.tsx
Normal file
|
|
@ -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 { usePathFormatter } from "../../context/path-format"
|
||||||
import { LocationProvider } from "../../context/location"
|
import { LocationProvider } from "../../context/location"
|
||||||
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
|
import { createSessionRows, type PartRef, type SessionRow } from "./rows"
|
||||||
|
import { ChildSessionPicker } from "./child-session-picker"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
|
|
@ -137,6 +138,10 @@ function use() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Session() {
|
export function Session() {
|
||||||
|
const route = useRouteData("session")
|
||||||
|
const [childPicker, setChildPicker] = createSignal(false)
|
||||||
|
|
||||||
|
function SessionView() {
|
||||||
const setEpilogue = useEpilogue()
|
const setEpilogue = useEpilogue()
|
||||||
const clipboard = useClipboard()
|
const clipboard = useClipboard()
|
||||||
const writeExport = async (file: string, content: string) => {
|
const writeExport = async (file: string, content: string) => {
|
||||||
|
|
@ -175,8 +180,23 @@ export function Session() {
|
||||||
if (session()?.parentID) return []
|
if (session()?.parentID) return []
|
||||||
return data.session.question.list(route.sessionID) ?? []
|
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 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 pending = createMemo(() => {
|
||||||
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
|
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
|
||||||
|
|
@ -726,11 +746,15 @@ export function Session() {
|
||||||
run: () => unavailable("Backgrounding subagents"),
|
run: () => unavailable("Backgrounding subagents"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Go to child session",
|
title: "View child agents",
|
||||||
value: "session.child.first",
|
value: "session.child.first",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
hidden: true,
|
hidden: true,
|
||||||
run: () => unavailable("Child session discovery"),
|
enabled: childSessions().length > 0 && !disabled(),
|
||||||
|
run: () => {
|
||||||
|
setChildPicker(true)
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Go to parent session",
|
title: "Go to parent session",
|
||||||
|
|
@ -863,7 +887,28 @@ export function Session() {
|
||||||
directory={session()?.location.directory}
|
directory={session()?.location.directory}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</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 />
|
<SubagentFooter />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={visible()}>
|
<Show when={visible()}>
|
||||||
|
|
@ -916,6 +961,13 @@ export function Session() {
|
||||||
</context.Provider>
|
</context.Provider>
|
||||||
</LocationProvider>
|
</LocationProvider>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={route.sessionID || undefined} keyed>
|
||||||
|
{(_sessionID: string) => <SessionView />}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessage | undefined }) {
|
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessage | undefined }) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue