feat(tui): add plugin-owned session panels (#47150)

This commit is contained in:
James Long
2026-09-04 17:37:45 -04:00
committed by GitHub
parent f268956c75
commit 6d791dfe67
13 changed files with 603 additions and 162 deletions
+24
View File
@@ -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
+33 -16
View File
@@ -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) {
<UpdateNotificationProvider
updater={input.updater}
>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
<PanelProvider>
<PluginProvider
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
</PluginProvider>
</PanelProvider>
</UpdateNotificationProvider>
</AttentionProvider>
</EditorContextProvider>
@@ -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
})
+63
View File
@@ -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 (
<box
id="session-panel"
ref={(value: BoxRenderable) => (node = value)}
flexGrow={1}
minWidth={0}
minHeight={0}
focusable
backgroundColor={theme.background.default}
onMouseDown={props.onFocus}
>
<Slot
path="session.panel"
input={{
name: props.panel.name,
sessionID: props.panel.sessionID,
get width() {
return props.width
},
get presentation() {
return panels.presentation()
},
get focused() {
return props.focused
},
focus: props.onFocus,
close: panels.close,
toggleFullscreen: panels.toggleFullscreen,
}}
/>
</box>
)
}
return (
<InteractivityProvider enabled={props.focused}>
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
<Content />
</ThemeContextProvider>
</InteractivityProvider>
)
}
+230 -60
View File
@@ -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<number>()
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}
>
<box
id="session-pane"
ref={(value: BoxRenderable) => (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)
}}
>
<Session
scrollRef={(value) => (sessionScroll = value)}
verticalTabsWidth={props.verticalTabsWidth}
promptMuted={terminalFocused()}
sidebarVisible={rightPane() === "sidebar"}
onToggleSidebar={toggleSidebar}
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
width={sessionWidth()}
/>
<Show when={terminalFocused()}>
<InteractivityProvider enabled={activePane() === "session" && !fullscreen()}>
<Session
scrollRef={(value) => (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()}
/>
</InteractivityProvider>
<Show when={activePane() === "right"}>
<box
position="absolute"
left={0}
@@ -174,35 +315,60 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}}
// Consume the release before revealing permission buttons underneath.
onMouseUp={() => {
if (terminalResize.resizing() || resizeRelease) return
if (paneResize.resizing() || resizeRelease) return
focusSession()
}}
/>
</Show>
</box>
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
<box
ref={(value: BoxRenderable) => (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}
>
<Show
when={rightPane() === "sidebar"}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={terminalResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
<Show
keyed
when={activePanel()}
fallback={
<Show keyed when={selectedTerminal()?.id}>
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={paneResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
setRestoreTerminalFocus(false)
}}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
}
>
{(item) => (
<PanelHost
panel={item}
width={fullscreen() ? availableWidth() : paneResize.size()}
focused={activePane() === "right"}
onFocus={focusRightPane}
onTarget={(node) => {
panelNode = node
if (node) {
focusRightPane()
return
}
setActivePane("session")
}}
onFocusChange={setTerminalFocused}
onFocusRequest={(value) => (focusTerminal = value)}
onDisconnect={() => setRestoreTerminalFocus(true)}
/>
)}
</Show>
@@ -212,12 +378,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
<PaneResizeHandle
resize={terminalResize}
left={availableWidth() - terminalResize.size() - 1}
highlight="right"
/>
<Show when={!fullscreen() && (rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
@@ -235,3 +397,11 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</box>
)
}
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
}
+1 -7
View File
@@ -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)
})
+1 -1
View File
@@ -93,7 +93,7 @@ export const Definitions = {
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
"terminal.select": keybind("<leader>down", "Select terminal"),
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
"terminal.close": keybind("<leader>up", "Close terminal pane"),
+58
View File
@@ -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<PanelTarget>()
const [requested, setRequested] = createSignal<PanelPresentation>("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<ReturnType<typeof createPanelState>>()
export function PanelProvider(props: ParentProps) {
return <Context.Provider value={createPanelState()}>{props.children}</Context.Provider>
}
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)
}
+18
View File
@@ -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<void>
@@ -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: () =>
+3 -71
View File
@@ -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<string>()
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: {
<Prompt
visible={true}
ref={bind}
disabled={false}
muted={props.promptMuted}
onSubmit={() => {
toBottom()
+1 -1
View File
@@ -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)))
+14 -1
View File
@@ -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 {
+70
View File
@@ -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()
})
})
@@ -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(() => <box><text>Acme</text></box>, () => console.log("closed"))
context.ui.dialog.show(
() => (
<box>
<text>Acme</text>
</box>
),
() => console.log("closed"),
)
context.ui.dialog.clear()
```
@@ -405,6 +409,84 @@ context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</t
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })
```
### 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) => (
<Show when={panel.name === "acme.review"}>
<ReviewPanel panel={panel} />
</Show>
),
})
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 <text>Reviewing {props.panel.sessionID}</text>
}
```
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.