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.