From 6d791dfe675fde64eb1d052fcbaec0ac85651810 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 4 Sep 2026 17:37:45 -0400 Subject: [PATCH] feat(tui): add plugin-owned session panels (#47150) --- packages/plugin/src/tui/context.ts | 24 ++ packages/tui/src/app.tsx | 49 ++- packages/tui/src/component/panel-host.tsx | 63 ++++ packages/tui/src/component/session-frame.tsx | 290 ++++++++++++++---- packages/tui/src/component/terminal-pane.tsx | 8 +- packages/tui/src/config/keybind.ts | 2 +- packages/tui/src/context/panel.tsx | 58 ++++ packages/tui/src/plugin/api.tsx | 18 ++ packages/tui/src/routes/session/index.tsx | 74 +---- packages/tui/src/ui/layout.ts | 2 +- packages/tui/test/app-lifecycle.test.tsx | 15 +- packages/tui/test/panel.test.ts | 70 +++++ .../src/docs/content/build/plugins/cli.mdx | 92 +++++- 13 files changed, 603 insertions(+), 162 deletions(-) create mode 100644 packages/tui/src/component/panel-host.tsx create mode 100644 packages/tui/src/context/panel.tsx create mode 100644 packages/tui/test/panel.test.ts diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 6a68b9ce8a2..4961c5eeba7 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -162,6 +162,21 @@ type PromptFooterInput = { readonly showDetails: boolean } +export type PanelPresentation = "panel" | "fullscreen" + +/** Client-local state of the selected session panel. The host owns its layout and input scope. */ +export interface PanelInput { + /** Selected content name, set by ui.panel.open. Contributions decide whether to render it. */ + readonly name: string + readonly sessionID: string + readonly width: number + readonly presentation: PanelPresentation + readonly focused: boolean + readonly focus: () => void + readonly close: () => void + readonly toggleFullscreen: () => void +} + /** * The host UI's slot tree. Every path is one slot: a named boundary a plugin * may render around, inside, or take over. Paths are absolute and @@ -180,6 +195,7 @@ export interface SlotMap { readonly "prompt.footer.status": PromptFooterInput readonly "prompt.footer.file": PromptFooterInput readonly "session.composer.top": { readonly sessionID: string } + readonly "session.panel": PanelInput readonly "sidebar.content": { readonly sessionID: string } readonly "sidebar.footer": { readonly sessionID: string } } @@ -450,6 +466,14 @@ export interface UI { navigate(destination: Destination): void current(): Route } + readonly panel: { + /** Opens the session.panel slot in the current session. */ + open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean + /** Closes this plugin's active panel. Other plugins' panels are unaffected. */ + close(): void + /** This plugin's active panel, if any. Reactive when read in a Solid computation. */ + current(): { readonly name: string; readonly sessionID: string } | undefined + } readonly tabs: { /** Returns whether session tabs are enabled for this TUI. */ enabled(): boolean diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b279fad93ad..721418a7209 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error" import { AttentionProvider } from "./context/attention" import { StorageProvider, useStorage } from "./context/storage" import { SessionTerminalsProvider } from "./context/session-terminals" +import { PanelProvider, usePanel } from "./context/panel" import { SessionFrame } from "./component/session-frame" import { createTuiClipboard } from "./clipboard" @@ -398,21 +399,23 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - + + + + + @@ -475,6 +478,7 @@ function App(props: { pair?: DialogPairCredentials }) { const dialog = useDialog() const local = useLocal() const sessionTabs = useSessionTabs() + const panels = usePanel() const keymap = Keymap.use() const event = useEvent() const client = useClient() @@ -576,9 +580,22 @@ function App(props: { pair?: DialogPairCredentials }) { const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full" const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize()) - const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin" + const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin" + const fullscreenPanel = () => + route.data.type === "session" && + panels.current()?.sessionID === route.data.sessionID && + panels.presentation() === "fullscreen" + const tabsVisible = () => tabsAvailable() && !fullscreenPanel() const verticalTabsVisible = () => tabsVisible() && tabsVertical() + // Measure the prospective split layout, even while full-screen hides the tabs. + createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0))) + createEffect(() => { + const current = panels.current() + if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return + panels.close() + }) + createEffect(() => { renderer.useMouse = config.data.mouse }) diff --git a/packages/tui/src/component/panel-host.tsx b/packages/tui/src/component/panel-host.tsx new file mode 100644 index 00000000000..944275871bb --- /dev/null +++ b/packages/tui/src/component/panel-host.tsx @@ -0,0 +1,63 @@ +import type { BoxRenderable } from "@opentui/core" +import { onCleanup, onMount } from "solid-js" +import { usePanel, type PanelTarget } from "../context/panel" +import { InteractivityProvider } from "../context/interactivity" +import { ThemeContextProvider, useTheme } from "../context/theme" +import { Slot } from "../plugin/render" + +export function PanelHost(props: { + panel: PanelTarget + width: number + focused: boolean + onFocus: () => void + onTarget: (node: BoxRenderable | undefined) => void +}) { + const panels = usePanel() + let node: BoxRenderable + onMount(() => props.onTarget(node)) + onCleanup(() => props.onTarget(undefined)) + + const Content = () => { + const theme = useTheme() + return ( + (node = value)} + flexGrow={1} + minWidth={0} + minHeight={0} + focusable + backgroundColor={theme.background.default} + onMouseDown={props.onFocus} + > + + + ) + } + + return ( + + + + + + ) +} diff --git a/packages/tui/src/component/session-frame.tsx b/packages/tui/src/component/session-frame.tsx index ab299948525..d8bac292afe 100644 --- a/packages/tui/src/component/session-frame.tsx +++ b/packages/tui/src/component/session-frame.tsx @@ -1,19 +1,30 @@ -import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core" +import { + CliRenderEvents, + RGBA, + MouseEvent, + type BoxRenderable, + type Renderable, + type ScrollBoxRenderable, +} from "@opentui/core" import { useRenderer, useTerminalDimensions } from "@opentui/solid" -import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js" +import { batch, createEffect, createMemo, createResource, createSignal, on, onCleanup, Show } from "solid-js" import { useConfig } from "../config" import { useData } from "../context/data" import { Keymap } from "../context/keymap" +import { InteractivityProvider } from "../context/interactivity" import { useSessionTerminals } from "../context/session-terminals" import { usePromptRef } from "../context/prompt" +import { usePanel } from "../context/panel" import { useStorage } from "../context/storage" +import { useDialog } from "../ui/dialog" import { Session } from "../routes/session" import { Sidebar } from "../routes/session/sidebar" -import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout" +import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout" import { createPaneResize } from "../ui/pane-resize" import { PaneResizeHandle } from "../ui/pane-resize-handle" import { useToast } from "../ui/toast" import { TerminalPane } from "./terminal-pane" +import { PanelHost } from "./panel-host" export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) { const sessions = useSessionTerminals() @@ -21,40 +32,49 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb const config = useConfig() const data = useData() const toast = useToast() + const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" }) const renderer = useRenderer() const dimensions = useTerminalDimensions() + const panels = usePanel() + const dialog = useDialog() const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth) - const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2)) - const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} }) - const terminalResize = createPaneResize({ - value: () => layout.terminalWidth ?? defaultTerminalWidth(), - defaultValue: defaultTerminalWidth, - clamp: (width) => clampTerminalPaneWidth(width, availableWidth()), + const defaultPaneWidth = () => Math.max(1, Math.floor(panels.width() / 2)) + const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", { + initial: {}, + }) + const paneResize = createPaneResize({ + value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(), + defaultValue: defaultPaneWidth, + clamp: (width) => clampSessionPaneWidth(width, panels.width()), fromMouse: (event) => dimensions().width - event.x - 1, contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width, onCommit: (width) => { void updateLayout((draft) => { - draft.terminalWidth = width + draft.paneWidth = width }).catch((error) => console.error("Failed to persist TUI layout", error)) }, }) let resizeRelease = false - const finishTerminalResize = (event: MouseEvent) => { - if (terminalResize.resizing()) { + const finishPaneResize = (event: MouseEvent) => { + if (paneResize.resizing()) { // A captured drag-end can be followed by mouse-up on the focus overlay. resizeRelease = true queueMicrotask(() => { resizeRelease = false }) } - terminalResize.onMouseUp(event) + paneResize.onMouseUp(event) } const [sidebarOpen, setSidebarOpen] = createSignal(false) const [sessionWidth, setSessionWidth] = createSignal() - const [terminalFocused, setTerminalFocused] = createSignal(false) + const [activePane, setActivePane] = createSignal<"session" | "right">("session") const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false) let focusTerminal: (() => void) | undefined + let showTerminals: (() => void) | undefined let sessionScroll: ScrollBoxRenderable | undefined + let sessionNode: BoxRenderable | undefined + let rightNode: BoxRenderable | undefined + let panelNode: BoxRenderable | undefined createResource( () => (config.data.session.terminal ? props.sessionID : undefined), (sessionID) => sessions.refresh(sessionID).catch(() => undefined), @@ -65,14 +85,23 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb const value = session() return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID) } + const activePanel = createMemo(() => { + const current = panels.current() + if (current?.sessionID === props.sessionID) return current + }) + const fullscreen = () => activePanel() !== undefined && panels.presentation() === "fullscreen" createEffect( - on( - () => selectedTerminal()?.id, - (id) => { - if (id) setSidebarOpen(false) - }, - { defer: true }, - ), + on([activePanel, () => selectedTerminal()?.id], ([panel, terminal], previous) => { + if (panel && panel !== previous?.[0]) { + setSidebarOpen(false) + if (terminal) void sessions.selectTerminal(props.sessionID, null).catch(toast.error) + return + } + if (terminal && terminal !== previous?.[1]) { + setSidebarOpen(false) + if (panel) panels.close() + } + }), ) const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120) const sidebarVisible = createMemo(() => { @@ -81,6 +110,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb return (config.data.session?.sidebar ?? "auto") === "auto" && wide() }) const rightPane = createMemo(() => { + if (activePanel()) return "panel" if (sidebarOpen() && sidebarVisible()) return "sidebar" if (selectedTerminal()) return "terminal" if (sidebarVisible()) return "sidebar" @@ -94,34 +124,137 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb }) .catch(toast.error) setSidebarOpen(!visible) + if (!visible && activePanel()) panels.close() if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error) }) } const focusSession = () => { + if (fullscreen()) return // Permission prompts replace the input, so returning focus must not depend on it. - if (terminalFocused()) renderer.currentFocusedRenderable?.blur() + if (activePane() === "right") renderer.currentFocusedRenderable?.blur() + setActivePane("session") prompt.current?.focus() } + const focusRightPane = () => { + setActivePane("right") + if (activePanel()) { + panelNode?.focus() + return + } + focusTerminal?.() + } + const onFocused = () => { + const current = renderer.currentFocusedRenderable + if (rightPane() !== "sidebar" && within(current, rightNode)) setActivePane("right") + if (!fullscreen() && within(current, sessionNode)) setActivePane("session") + } + renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused) + onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)) + createEffect(() => { + if (fullscreen()) focusRightPane() + }) + createEffect(() => { + if (rightPane() !== "terminal" && rightPane() !== "panel") setActivePane("session") + }) createEffect(() => { if (!restoreTerminalFocus() || selectedTerminal()) return setRestoreTerminalFocus(false) focusSession() }) Keymap.createLayer(() => ({ - enabled: () => config.data.session.terminal === true, + mode: "global", + enabled: () => (rightPane() === "terminal" || activePanel() !== undefined) && dialog.stack.length === 0, commands: [ { id: "pane.focus.left", title: "Focus session pane", + enabled: () => !fullscreen(), run: focusSession, }, { id: "pane.focus.right", - title: "Focus terminal pane", + title: "Focus right pane", + run: focusRightPane, + }, + ], + })) + + // Pane management stays reachable from either input scope. + Keymap.createLayer(() => ({ + mode: "global", + commands: [ + { + id: "session.sidebar.toggle", + title: rightPane() === "sidebar" ? "Hide sidebar" : "Show sidebar", + group: "Session", + palette: true, run: () => { - focusTerminal?.() + toggleSidebar() + dialog.clear() }, }, + ...(config.data.session.terminal + ? [ + { + id: "terminal.toggle", + title: rightPane() === "terminal" ? "Hide terminal pane" : "Show terminal pane", + group: "Session", + palette: true as const, + run: () => { + dialog.clear() + if (rightPane() === "terminal") { + focusSession() + void sessions.selectTerminal(props.sessionID, null).catch(toast.error) + return + } + void sessions + .refresh(props.sessionID) + .then(async () => { + const terminal = sessions.get(props.sessionID).terminals.at(-1) + if (terminal) return sessions.selectTerminal(props.sessionID, terminal.id) + await sessions.newTerminal(props.sessionID) + }) + .catch(terminalError) + }, + }, + { + id: "terminal.select", + title: "Select terminal", + group: "Session", + palette: true as const, + run: () => { + dialog.clear() + if (fullscreen()) panels.close() + focusSession() + showTerminals?.() + void sessions.refresh(props.sessionID).catch(terminalError) + }, + }, + { + id: "terminal.close", + title: "Close terminal pane", + group: "Session", + palette: true as const, + enabled: rightPane() === "terminal", + run: () => { + dialog.clear() + focusSession() + void sessions.selectTerminal(props.sessionID, null).catch(toast.error) + }, + }, + { + id: "session.terminal", + title: "New terminal", + group: "Session", + palette: true as const, + slash: { name: "terminal" }, + run: async () => { + dialog.clear() + await sessions.newTerminal(props.sessionID).catch(terminalError) + }, + }, + ] + : []), ], })) @@ -132,30 +265,38 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb minHeight={0} flexDirection="row" position="relative" - onMouseDrag={terminalResize.onMouseDrag} - onMouseDragEnd={finishTerminalResize} - onMouseUp={finishTerminalResize} + onMouseDrag={paneResize.onMouseDrag} + onMouseDragEnd={finishPaneResize} + onMouseUp={finishPaneResize} > (sessionNode = value)} flexGrow={1} flexBasis={0} minWidth={0} minHeight={0} - position="relative" + position={fullscreen() ? "absolute" : "relative"} + visible={!fullscreen()} + width={fullscreen() ? Math.max(0, panels.width() - paneResize.size()) : undefined} + height="100%" onSizeChange={function () { setSessionWidth(this.width) }} > - (sessionScroll = value)} - verticalTabsWidth={props.verticalTabsWidth} - promptMuted={terminalFocused()} - sidebarVisible={rightPane() === "sidebar"} - onToggleSidebar={toggleSidebar} - visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined} - width={sessionWidth()} - /> - + + (sessionScroll = value)} + verticalTabsWidth={props.verticalTabsWidth} + promptMuted={activePane() !== "session"} + sidebarVisible={rightPane() === "sidebar"} + onToggleSidebar={toggleSidebar} + visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined} + onTerminalPicker={(show) => (showTerminals = show)} + width={sessionWidth()} + /> + + { - if (terminalResize.resizing() || resizeRelease) return + if (paneResize.resizing() || resizeRelease) return focusSession() }} /> - + (rightNode = value)} flexShrink={0} - width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH} + width={ + fullscreen() ? availableWidth() : rightPane() === "sidebar" ? SESSION_SIDEBAR_WIDTH : paneResize.size() + } minWidth={0} minHeight={0} > - {(ptyID) => ( - { - sessions.clearFocus(ptyID) - setRestoreTerminalFocus(false) + + {(ptyID) => ( + { + sessions.clearFocus(ptyID) + setRestoreTerminalFocus(false) + }} + onFocusRequest={(value) => (focusTerminal = value)} + onDisconnect={() => setRestoreTerminalFocus(true)} + /> + )} + + } + > + {(item) => ( + { + panelNode = node + if (node) { + focusRightPane() + return + } + setActivePane("session") }} - onFocusChange={setTerminalFocused} - onFocusRequest={(value) => (focusTerminal = value)} - onDisconnect={() => setRestoreTerminalFocus(true)} /> )} @@ -212,12 +378,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb - = 3}> - + = 3}> + ) } + +function within(node: Renderable | null | undefined, root: Renderable | undefined) { + if (!root) return false + for (let current = node; current; current = current.parent) { + if (current === root) return true + } + return false +} diff --git a/packages/tui/src/component/terminal-pane.tsx b/packages/tui/src/component/terminal-pane.tsx index ea600d06b4d..74d79609446 100644 --- a/packages/tui/src/component/terminal-pane.tsx +++ b/packages/tui/src/component/terminal-pane.tsx @@ -1,4 +1,4 @@ -import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } from "@opentui/core" +import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core" import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui" import { extend, useRenderer } from "@opentui/solid" import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js" @@ -28,7 +28,6 @@ export function TerminalPane(props: { onAutoFocus?: () => void onFocusRequest?: (focus: (() => void) | undefined) => void onDisconnect?: () => void - onFocusChange?: (focused: boolean) => void }) { const client = useClient() const keymap = Keymap.use() @@ -148,9 +147,6 @@ export function TerminalPane(props: { }, { priority: 100 }, ) - // Blur emits this event before updating the terminal's own focused flag. - const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === terminal) - renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused) createEffect(() => { if (!props.autoFocus || !terminal) return terminal.focus() @@ -172,8 +168,6 @@ export function TerminalPane(props: { waitingSize?.resolve() socket?.close() offKeys() - renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused) - props.onFocusChange?.(false) props.onFocusRequest?.(undefined) }) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 264e09792f4..d433e88f219 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -93,7 +93,7 @@ export const Definitions = { "theme.mode.lock": keybind("none", "Lock or unlock theme mode"), "session.sidebar.toggle": keybind("b", "Toggle sidebar"), "pane.focus.left": keybind("left", "Focus session pane"), - "pane.focus.right": keybind("right", "Focus terminal pane"), + "pane.focus.right": keybind("right", "Focus right pane"), "terminal.select": keybind("down", "Select terminal"), "terminal.toggle": keybind("t", "Toggle terminal pane"), "terminal.close": keybind("up", "Close terminal pane"), diff --git a/packages/tui/src/context/panel.tsx b/packages/tui/src/context/panel.tsx new file mode 100644 index 00000000000..e947c34f89d --- /dev/null +++ b/packages/tui/src/context/panel.tsx @@ -0,0 +1,58 @@ +import type { PanelPresentation } from "@opencode-ai/plugin/tui/context" +import { batch, createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js" + +export type PanelTarget = { + readonly plugin: string + readonly name: string + readonly sessionID: string +} + +export function createPanelState() { + const [current, setCurrent] = createSignal() + const [requested, setRequested] = createSignal("panel") + const [width, setWidth] = createSignal(0) + const canSplit = () => width() > 80 + const presentation = createMemo(() => (canSplit() ? requested() : "fullscreen")) + return { + current, + width, + canSplit, + presentation, + setWidth, + open(target: PanelTarget, presentation: PanelPresentation = "panel") { + batch(() => { + setRequested(presentation) + setCurrent((current) => + current?.plugin === target.plugin && current.name === target.name && current.sessionID === target.sessionID + ? current + : target, + ) + }) + }, + close: () => setCurrent(), + release(plugin: string) { + if (current()?.plugin !== plugin) return + setCurrent() + }, + toggleFullscreen() { + if (!canSplit()) return + setRequested((current) => (current === "panel" ? "fullscreen" : "panel")) + }, + } +} + +const Context = createContext>() + +export function PanelProvider(props: ParentProps) { + return {props.children} +} + +export function usePanel() { + const value = useContext(Context) + if (!value) throw new Error("usePanel must be used within a PanelProvider") + return value +} + +export function useOptionalPanel() { + return useContext(Context) +} diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx index 2faa830b8ea..7708695214a 100644 --- a/packages/tui/src/plugin/api.tsx +++ b/packages/tui/src/plugin/api.tsx @@ -20,6 +20,7 @@ import { useToast } from "../ui/toast" import { useAttention } from "../context/attention" import { useStorage } from "../context/storage" import { useSessionTabs } from "../context/session-tabs" +import { useOptionalPanel } from "../context/panel" import { abbreviateHome } from "../util/path-format" export type Dispose = () => Promise @@ -68,6 +69,7 @@ export function usePluginHost() { attention: useAttention(), storage: useStorage(), sessionTabs: useSessionTabs(), + panel: useOptionalPanel(), } } @@ -82,6 +84,7 @@ export function createPluginContext(input: { registry: Registry }): Context { const host = input.host + input.owned.push(async () => host.panel?.release(input.id)) let context: Context let claims = 0 // Every dialog and registered render is wrapped so plugin components can @@ -174,6 +177,21 @@ export function createPluginContext(input: { return host.route.data }, }, + panel: { + open(name, options) { + if (!host.panel || !input.registry.active()) return false + const route = host.route.data + if (route.type !== "session") return false + host.panel.open({ plugin: input.id, name, sessionID: route.sessionID }, options?.presentation) + return true + }, + close: () => host.panel?.release(input.id), + current() { + const current = host.panel?.current() + if (current?.plugin !== input.id) return + return { name: current.name, sessionID: current.sessionID } + }, + }, tabs: { enabled: host.sessionTabs.enabled, list: () => diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e99dc32ad28..1e117a532fa 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -113,7 +113,6 @@ import { createDelayedPresence } from "../../util/delayed-presence" import { SessionLocationMissing } from "./location-missing" import { isRecord } from "../../util/record" import { createHistoryPrepend } from "./history" -import { useSessionTerminals } from "../../context/session-terminals" addDefaultParsers(parsers.parsers) @@ -161,6 +160,7 @@ export function Session(props: { sidebarVisible: boolean onToggleSidebar: () => void visibleTerminalID?: string + onTerminalPicker?: (show: (() => void) | undefined) => void width?: number }) { const setEpilogue = useEpilogue() @@ -234,6 +234,8 @@ export function Session(props: { open: false, tab: undefined as string | undefined, }) + props.onTerminalPicker?.(() => setComposer({ open: true, tab: "terminals" })) + onCleanup(() => props.onTerminalPicker?.(undefined)) createEffect(() => { if (props.promptMuted && composer.open) setComposer("open", false) }) @@ -260,7 +262,6 @@ export function Session(props: { const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const toast = useToast() - const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" }) const client = useClient() const autoApproved = new Set() createEffect(() => { @@ -295,7 +296,6 @@ export function Session(props: { const [firstJump, setFirstJump] = createSignal<() => void>() const [synced, setSynced] = createSignal(false) const sessionTabs = useSessionTabs() - const terminals = useSessionTerminals() const [awayFromBottom, setAwayFromBottom] = createSignal(false) const [latestHovered, setLatestHovered] = createSignal(false) let ensureAllRowsPending: (() => void)[] | undefined @@ -984,73 +984,6 @@ export function Session(props: { })() }, }, - { - title: props.sidebarVisible ? "Hide sidebar" : "Show sidebar", - id: "session.sidebar.toggle", - group: "Session", - run: () => { - props.onToggleSidebar() - dialog.clear() - }, - }, - ...(config.session.terminal - ? [ - { - title: props.visibleTerminalID ? "Hide terminal pane" : "Show terminal pane", - id: "terminal.toggle", - group: "Session", - run: () => { - const sessionID = route.sessionID - if (props.visibleTerminalID) { - promptRef.current?.focus() - void terminals.selectTerminal(sessionID, null).catch(toast.error) - } else { - void terminals - .refresh(sessionID) - .then(async () => { - const terminal = terminals.get(sessionID).terminals.at(-1) - if (terminal) return terminals.selectTerminal(sessionID, terminal.id) - await terminals.newTerminal(sessionID) - }) - .catch(terminalError) - } - dialog.clear() - }, - }, - { - title: "Select terminal", - id: "terminal.select", - group: "Session", - run: () => { - promptRef.current?.focus() - setComposer({ open: true, tab: "terminals" }) - void terminals.refresh(route.sessionID).catch(terminalError) - dialog.clear() - }, - }, - { - title: "Close terminal pane", - id: "terminal.close", - group: "Session", - enabled: props.visibleTerminalID !== undefined, - run: () => { - promptRef.current?.focus() - void terminals.selectTerminal(route.sessionID, null).catch(toast.error) - dialog.clear() - }, - }, - { - title: "New terminal", - id: "session.terminal", - group: "Session", - slash: { name: "terminal" }, - run: async () => { - dialog.clear() - await terminals.newTerminal(route.sessionID).catch(terminalError) - }, - }, - ] - : []), { title: (() => { const next = nextThinkingMode(thinkingMode()) @@ -1515,7 +1448,6 @@ export function Session(props: { { toBottom() diff --git a/packages/tui/src/ui/layout.ts b/packages/tui/src/ui/layout.ts index 85aa92ca5e8..104612c50da 100644 --- a/packages/tui/src/ui/layout.ts +++ b/packages/tui/src/ui/layout.ts @@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) { ) } -export function clampTerminalPaneWidth(width: number, total: number) { +export function clampSessionPaneWidth(width: number, total: number) { const half = Math.max(1, Math.floor(total / 2)) // Preserve the equal split when there is not enough room for both pane minima. return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH))) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 612ec26a1ba..4f0a067758a 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -1228,7 +1228,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () => }) test.each(["manual", "select"] as const)( - "selection copy and dismissal respect %s mode in the prompt and terminal pane", + "selection copy and pane management respect %s mode in the prompt and terminal pane", async (copy) => { const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) setup.renderer.start() @@ -1359,6 +1359,19 @@ test.each(["manual", "select"] as const)( expect(setup.renderer.hasSelection).toBeFalse() expect(setup.renderer.isDestroyed).toBeFalse() + setup.mockInput.pressKey("x", { ctrl: true }) + setup.mockInput.pressArrow("up") + await setup.waitFor(() => terminal.isDestroyed) + expect(setup.renderer.currentFocusedEditor?.plainText).toBe("") + setup.mockInput.pressKey("x", { ctrl: true }) + setup.mockInput.pressKey("t") + await setup.waitForFrame((frame) => frame.includes("alpha beta gamma")) + expect(setup.renderer.currentFocusedRenderable).toBeInstanceOf(EmbeddedTerminalRenderable) + setup.mockInput.pressKey("x", { ctrl: true }) + setup.mockInput.pressArrow("down") + await setup.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Terminals")) + expect(setup.renderer.currentFocusedRenderable).not.toBeInstanceOf(EmbeddedTerminalRenderable) + setup.renderer.destroy() await task } finally { diff --git a/packages/tui/test/panel.test.ts b/packages/tui/test/panel.test.ts new file mode 100644 index 00000000000..7d610241026 --- /dev/null +++ b/packages/tui/test/panel.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createPanelState } from "../src/context/panel" + +test("presentation changes preserve the selected panel identity", () => { + createRoot((dispose) => { + const panels = createPanelState() + panels.setWidth(160) + panels.open({ plugin: "review", name: "diff", sessionID: "session" }) + const current = panels.current() + expect(panels.presentation()).toBe("panel") + panels.toggleFullscreen() + expect(panels.presentation()).toBe("fullscreen") + expect(panels.current()).toBe(current) + panels.toggleFullscreen() + expect(panels.presentation()).toBe("panel") + expect(panels.current()).toBe(current) + panels.open({ plugin: "review", name: "diff", sessionID: "session" }) + expect(panels.current()).toBe(current) + dispose() + }) +}) + +test("narrow geometry overrides presentation without discarding the user's choice", () => { + createRoot((dispose) => { + const panels = createPanelState() + panels.open({ plugin: "review", name: "diff", sessionID: "session" }) + panels.setWidth(80) + expect(panels.canSplit()).toBe(false) + expect(panels.presentation()).toBe("fullscreen") + panels.toggleFullscreen() + panels.setWidth(81) + expect(panels.canSplit()).toBe(true) + expect(panels.presentation()).toBe("panel") + panels.toggleFullscreen() + panels.setWidth(60) + panels.setWidth(160) + expect(panels.presentation()).toBe("fullscreen") + dispose() + }) +}) + +test("opening a different name changes the panel selection", () => { + createRoot((dispose) => { + const panels = createPanelState() + panels.open({ plugin: "review", name: "review.diff", sessionID: "session" }) + const current = panels.current() + panels.open({ plugin: "review", name: "review.history", sessionID: "session" }) + expect(panels.current()).not.toBe(current) + expect(panels.current()?.name).toBe("review.history") + panels.open({ plugin: "tasks", name: "tasks.list", sessionID: "session" }) + expect(panels.current()).toEqual({ plugin: "tasks", name: "tasks.list", sessionID: "session" }) + panels.release("review") + expect(panels.current()?.name).toBe("tasks.list") + dispose() + }) +}) + +test("releasing a plugin contribution only closes its own selected panel", () => { + createRoot((dispose) => { + const panels = createPanelState() + panels.open({ plugin: "review", name: "diff", sessionID: "session" }) + const current = panels.current() + panels.release("other") + expect(panels.current()).toBe(current) + panels.release("review") + expect(panels.current()).toBeUndefined() + dispose() + }) +}) diff --git a/packages/www/src/docs/content/build/plugins/cli.mdx b/packages/www/src/docs/content/build/plugins/cli.mdx index 4cbb177c28e..50314537e06 100644 --- a/packages/www/src/docs/content/build/plugins/cli.mdx +++ b/packages/www/src/docs/content/build/plugins/cli.mdx @@ -236,10 +236,7 @@ function Status() { Register a fenced-code renderer by language; the returned function unregisters it. ```ts -const unregister = context.markdown.registerCodeBlockRenderer( - "acme", - (_token, render) => render.defaultRender(), -) +const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender()) return unregister ``` @@ -340,7 +337,14 @@ Custom JSX dialogs can set their size and close themselves. ```tsx context.ui.dialog.set({ size: "large", centered: true }) -context.ui.dialog.show(() => Acme, () => console.log("closed")) +context.ui.dialog.show( + () => ( + + Acme + + ), + () => console.log("closed"), +) context.ui.dialog.clear() ``` @@ -405,6 +409,84 @@ context.ui.slot({ after: "home.footer", render: () => After footer slot New footer }) ``` +### Session panels + +Register a contribution to `session.panel`, then open the panel from a command. The host owns sizing, focus, and +full-screen presentation; the plugin owns its contents. The selected name is passed to every contribution as +`panel.name`, and each contribution decides whether to render. + +```tsx +import { Show } from "solid-js" + +context.ui.slot({ + append: "session.panel", + render: (panel) => ( + + + + ), +}) + +context.ui.slot({ + append: "app", + render: () => { + context.keymap.layer(() => ({ + mode: "global", + commands: [ + { + id: "acme.review", + title: "Open review", + slash: { name: "review" }, + run: () => { + context.ui.panel.open("acme.review") + }, + }, + ], + })) + return null + }, +}) +``` + +- Opening outside a session returns `false`. +- This is an ordinary slot: all five placements and the existing replacement ordering rules apply. +- Use `append` for independently selectable contributions so they can coexist. `replace` still takes over the slot. +- Names are shared selection values, not registered claims. Use a plugin-prefixed name such as `acme.review` to avoid collisions. Opening a name with no matching renderer leaves the slot empty. +- Changing presentation preserves the mounted contributions. Closing the panel disposes them; disabling a plugin removes its contributions through normal slot cleanup. +- Its keyboard layers and input modes are active only while the panel owns input. + +The slot receives reactive `name`, `sessionID`, `width`, `presentation`, and `focused` properties, plus `focus`, +`close`, and `toggleFullscreen` actions. The host keeps narrow terminals full-screen; `toggleFullscreen` has no effect +until there is enough room for a side panel. + +```tsx +import type { PanelInput } from "@opencode-ai/plugin/tui/context" +import { usePlugin } from "@opencode-ai/plugin/tui" + +function ReviewPanel(props: { panel: PanelInput }) { + const context = usePlugin() + context.keymap.layer(() => ({ + commands: [ + { + id: "acme.review.fullscreen", + bind: "f", + run: props.panel.toggleFullscreen, + }, + ], + })) + return Reviewing {props.panel.sessionID} +} +``` + +You can request full-screen presentation initially, inspect your active panel, or close it without affecting another +plugin's panel. + +```ts +context.ui.panel.open("acme.review", { presentation: "fullscreen" }) +const current = context.ui.panel.current() +context.ui.panel.close() +``` + ## Formatting Format filesystem paths for display, including home-directory abbreviation.