Files
ClosedCode/packages/tui/src/routes/session/composer/subagents-tab.tsx
T

248 lines
8.3 KiB
TypeScript

import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { useRoute, useRouteData } from "../../../context/route"
import { useData } from "../../../context/data"
import { useSDK } from "../../../context/sdk"
import { useTheme, selectedForeground } from "../../../context/theme"
import { Locale } from "../../../util/locale"
import { useBindings, useCommandShortcut } from "../../../keymap"
import { useComposerTab } from "./index"
interface SubagentEntry {
sessionID: string
agent: string
title: string
status: string
current: boolean
}
export function SubagentsTab(props: { sessionID: string }) {
const route = useRouteData("session")
const data = useData()
const sdk = useSDK()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const navigate = useRoute().navigate
const composer = useComposerTab()
const interruptHint = useCommandShortcut("composer.subagent.interrupt")
const session = createMemo(() => data.session.get(props.sessionID))
const entries = createMemo<SubagentEntry[]>(() => {
const current = session()
if (!current) return []
const result: SubagentEntry[] = []
if (current.parentID) {
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
for (const sibling of siblings) {
const agentMatch = sibling.title.match(/@(\w+) subagent/)
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
result.push({
sessionID: sibling.id,
agent,
title: name,
status: data.session.status(sibling.id),
current: sibling.id === route.sessionID,
})
}
} else {
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
for (const child of children) {
const agentMatch = child.title.match(/@(\w+) subagent/)
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
result.push({
sessionID: child.id,
agent,
title: name,
status: data.session.status(child.id),
current: child.id === route.sessionID,
})
}
}
return result
})
const [store, setStore] = createStore({ selected: 0 })
let selectedSessionID = ""
let wasActive = false
let scroll: ScrollBoxRenderable | undefined
const selected = createMemo(() => {
return store.selected
})
const selectedEntry = createMemo(() => entries()[selected()])
createEffect(() => {
const active = composer.active("subagents")
if (!active) {
if (wasActive) {
selectedSessionID = ""
setStore("selected", 0)
}
wasActive = false
return
}
const list = entries()
if (selectedSessionID !== route.sessionID && list.length > 0) {
const currentIdx = list.findIndex((e) => e.current)
const next = currentIdx >= 0 ? currentIdx : 0
selectedSessionID = route.sessionID
setStore("selected", next)
const scrollCurrentIntoView = () => scrollToIndex(next, true)
scrollCurrentIntoView()
requestAnimationFrame(scrollCurrentIntoView)
}
wasActive = true
if (store.selected >= list.length) moveTo(Math.max(0, list.length - 1))
})
function moveTo(next: number, center = false) {
setStore("selected", next)
scrollToSelection(center)
}
function scrollToSelection(center: boolean) {
scrollToIndex(selected(), center)
}
function scrollToIndex(index: number, center: boolean) {
if (!scroll) return
if (center) {
scroll.scrollTo(Math.max(0, index - Math.floor(scroll.viewport.height / 2)))
return
}
if (index >= scroll.scrollTop + scroll.viewport.height) {
scroll.scrollTo(index - scroll.viewport.height + 1)
}
if (index < scroll.scrollTop) {
scroll.scrollTo(index)
if (index === 0) scroll.scrollTo(0)
}
}
onMount(() => {
const cleanup = composer.register({
id: "subagents",
label: "Subagents",
hints: () => {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return []
return [{ label: "interrupt", shortcut: interruptHint() }]
},
onClose: () => {
const parentID = session()?.parentID
if (parentID) navigate({ type: "session", sessionID: parentID })
},
})
onCleanup(cleanup)
})
useBindings(() => ({
mode: "composer",
enabled: () => composer.active("subagents"),
commands: [
{
name: "composer.subagent.up",
title: "Previous subagent",
category: "Composer",
run() {
const list = entries()
if (list.length === 0) return
moveTo((store.selected - 1 + list.length) % list.length, true)
},
},
{
name: "composer.subagent.down",
title: "Next subagent",
category: "Composer",
run() {
const list = entries()
if (list.length === 0) return
moveTo((store.selected + 1) % list.length, true)
},
},
{
name: "composer.subagent.select",
title: "Navigate to subagent",
category: "Composer",
run() {
const entry = entries()[store.selected]
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
},
},
{
name: "composer.subagent.interrupt",
title: "Interrupt subagent",
category: "Composer",
run() {
const entry = selectedEntry()
if (!entry || entry.status !== "running") return
void sdk.api.session.interrupt({ sessionID: entry.sessionID })
},
},
],
bindings: [
{ key: "up", desc: "Previous subagent", group: "Subagents", cmd: "composer.subagent.up" },
{ key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" },
{ key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" },
{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" },
],
}))
return (
<Show when={composer.active("subagents")}>
<scrollbox
scrollbarOptions={{ visible: false }}
maxHeight={5}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
>
<Show when={entries().length > 0} fallback={<text fg={theme.textMuted}> No subagents</text>}>
<For each={entries()}>
{(entry, index) => {
const active = createMemo(() => index() === selected())
const status = createMemo(() => {
if (entry.status === "running") return "Running"
return ""
})
return (
<box
flexDirection="row"
paddingLeft={1}
paddingRight={1}
backgroundColor={active() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setStore("selected", index())}
onMouseUp={() => {
setStore("selected", index())
navigate({ type: "session", sessionID: entry.sessionID })
}}
>
<box flexGrow={1} minWidth={0} flexDirection="row">
<text
fg={active() ? fg : entry.current ? theme.primary : theme.text}
attributes={active() ? TextAttributes.BOLD : undefined}
wrapMode="none"
>
{entry.agent}: {entry.title}
</text>
</box>
<Show when={status()}>
<text fg={active() ? fg : theme.textMuted} wrapMode="none">
{status()}
</text>
</Show>
</box>
)
}}
</For>
</Show>
</scrollbox>
</Show>
)
}