diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 32a673f506..c14516b0af 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -626,6 +626,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ) }, + async remove(id: string) { + await sdk.client.v2.shell.remove({ id }, { throwOnError: true }) + setStore("shell", id, undefined!) + }, }, location: { default() { diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx new file mode 100644 index 0000000000..ba0fb7ae83 --- /dev/null +++ b/packages/tui/src/routes/session/composer/index.tsx @@ -0,0 +1,183 @@ +import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js" +import { createStore } from "solid-js/store" +import { TextAttributes } from "@opentui/core" +import { useTheme } from "../../../context/theme" +import { SplitBorder } from "../../../ui/border" +import { useBindings, useOpencodeModeStack, useCommandShortcut } from "../../../keymap" +import { SubagentsTab } from "./subagents-tab" +import { ShellTab } from "./shell-tab" + +export interface ComposerHint { + label: string + shortcut: string +} + +interface Tab { + id: string + label: string + hints?: () => ComposerHint[] + onClose?: () => void +} + +const ComposerContext = createContext<{ + register: (tab: Tab) => () => void + active: (id: string) => boolean +}>() + +export function useComposerTab() { + const ctx = useContext(ComposerContext) + if (!ctx) throw new Error("useComposerTab must be used within a Composer") + return ctx +} + +export type ComposerProps = { + sessionID: string + open: boolean + defaultTab?: string + onClose?: () => void +} + +export function Composer(props: ComposerProps) { + const { theme } = useTheme() + + const [store, setStore] = createStore({ + tabs: {} as Record, + active: "", + }) + + const tabList = createMemo(() => Object.values(store.tabs)) + const activeTab = createMemo(() => tabList().find((t) => t.id === store.active)) + const footerHints = createMemo(() => activeTab()?.hints?.() ?? []) + + // Set active tab when opened + createEffect(() => { + if (!props.open) return + const tabs = tabList() + if (tabs.length === 0) return + const match = props.defaultTab && tabs.find((t) => t.id === props.defaultTab) + setStore("active", match ? match.id : tabs[0].id) + }) + + function close() { + const tab = activeTab() + tab?.onClose?.() + props.onClose?.() + } + + const ctx = { + register(tab: Tab) { + setStore("tabs", tab.id, tab) + if (!store.active) setStore("active", tab.id) + return () => setStore("tabs", tab.id, undefined!) + }, + active(id: string) { + return props.open && store.active === id + }, + } + + const modeStack = useOpencodeModeStack() + createEffect(() => { + if (!props.open) return + const popMode = modeStack.push("composer") + onCleanup(popMode) + }) + + const switchTab = (dir: number) => { + const tabs = tabList() + if (tabs.length <= 1) return + const idx = tabs.findIndex((t) => t.id === store.active) + setStore("active", tabs[(idx + dir + tabs.length) % tabs.length].id) + } + + useBindings(() => ({ + mode: "composer", + enabled: () => props.open, + bindings: [ + { key: "left", desc: "Previous tab", group: "Composer", cmd: () => switchTab(-1) }, + { key: "right", desc: "Next tab", group: "Composer", cmd: () => switchTab(1) }, + { key: "escape", desc: "Close composer", group: "Composer", cmd: close }, + { + key: "down", + desc: "Toggle composer", + group: "Composer", + cmd: close, + }, + ], + })) + + const closeHint = useCommandShortcut("session.child_first") + + return ( + + + + + 1} + fallback={ + + + {tabList()[0]?.label ?? ""} + + + } + > + + + {(t) => { + const isActive = createMemo(() => store.active === t.id) + return ( + + {t.label} + + ) + }} + + + + + + + + {(hint) => ( + + + {hint.label}{" "} + + {hint.shortcut} + + )} + + 1}> + + + tabs{" "} + + ←/→ + + + + + close{" "} + + {closeHint()} + + + + + + + ) +} diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx new file mode 100644 index 0000000000..caa76ca798 --- /dev/null +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -0,0 +1,141 @@ +import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core" +import { useData } from "../../../context/data" +import { useTheme, selectedForeground } from "../../../context/theme" +import { useBindings, useCommandShortcut } from "../../../keymap" +import { useComposerTab } from "./index" + +export function ShellTab(props: { sessionID: string }) { + const data = useData() + const { theme } = useTheme() + const fg = selectedForeground(theme) + const composer = useComposerTab() + const killHint = useCommandShortcut("composer.shell.kill") + const backgroundHint = useCommandShortcut("composer.background") + + const entries = createMemo(() => + data.shell + .list() + .filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"), + ) + + const [store, setStore] = createStore({ selected: 0 }) + let scroll: ScrollBoxRenderable | undefined + + const selectedEntry = createMemo(() => entries()[store.selected]) + + createEffect(() => { + if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1)) + }) + + createEffect(() => { + if (!scroll) return + const target = scroll.getChildren()[store.selected] + if (!target) return + const y = target.y - scroll.y + if (y >= scroll.height || y < 0) { + const center = Math.floor(scroll.height / 2) + scroll.scrollBy(y - center) + } + }) + + onMount(() => { + const cleanup = composer.register({ + id: "shell", + label: "Shell", + hints: () => + selectedEntry() + ? [ + { label: "kill", shortcut: killHint() }, + { label: "background", shortcut: backgroundHint() }, + ] + : [], + }) + onCleanup(cleanup) + }) + + useBindings(() => ({ + mode: "composer", + enabled: () => composer.active("shell"), + commands: [ + { + name: "composer.shell.up", + title: "Previous shell", + category: "Composer", + run() { + const list = entries() + if (list.length === 0) return + setStore("selected", (prev) => (prev - 1 + list.length) % list.length) + }, + }, + { + name: "composer.shell.down", + title: "Next shell", + category: "Composer", + run() { + const list = entries() + if (list.length === 0) return + setStore("selected", (prev) => (prev + 1) % list.length) + }, + }, + { + name: "composer.shell.kill", + title: "Kill shell command", + category: "Composer", + run() { + const entry = selectedEntry() + if (!entry) return + void data.shell.remove(entry.id) + }, + }, + { + name: "composer.background", + title: "Background shell command", + category: "Composer", + run() {}, + }, + ], + bindings: [ + { key: "up", desc: "Previous shell", group: "Shell", cmd: "composer.shell.up" }, + { key: "down", desc: "Next shell", group: "Shell", cmd: "composer.shell.down" }, + { key: "ctrl+d", desc: "Kill shell command", group: "Shell", cmd: "composer.shell.kill" }, + { key: "ctrl+b", desc: "Background shell command", group: "Shell", cmd: "composer.background" }, + ], + })) + + return ( + + (scroll = r)} + > + 0} fallback={ No shell commands}> + + {(shell, index) => { + const active = createMemo(() => index() === store.selected) + return ( + setStore("selected", index())} + > + + {shell.command} + + + ) + }} + + + + + ) +} diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx new file mode 100644 index 0000000000..9090a06542 --- /dev/null +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -0,0 +1,258 @@ +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 { 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 { theme } = useTheme() + const fg = selectedForeground(theme) + const navigate = useRoute().navigate + const composer = useComposerTab() + const interruptHint = useCommandShortcut("composer.subagent.interrupt") + const backgroundHint = useCommandShortcut("composer.background") + + const session = createMemo(() => data.session.get(props.sessionID)) + + const entries = createMemo(() => { + 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() }, + ...(entry.current ? [{ label: "background", shortcut: backgroundHint() }] : []), + ] + }, + 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 + }, + }, + { + name: "composer.background", + title: "Background subagent", + category: "Composer", + run() { + const entry = selectedEntry() + if (!entry || entry.status !== "running" || !entry.current) return + }, + }, + ], + 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" }, + { key: "ctrl+b", desc: "Background subagent", group: "Subagents", cmd: "composer.background" }, + ], + })) + + return ( + + (scroll = r)} + > + 0} fallback={No subagents}> + + {(entry, index) => { + const active = createMemo(() => index() === selected()) + const status = createMemo(() => { + if (entry.status === "running") return "Running" + return "" + }) + return ( + setStore("selected", index())} + onMouseUp={() => { + setStore("selected", index()) + navigate({ type: "session", sessionID: entry.sessionID }) + }} + > + + + {entry.agent}: {entry.title} + + + + + {status()} + + + + ) + }} + + + + + ) +}