summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDaniel Polito <[email protected]>2026-01-16 13:51:02 -0300
committerGitHub <[email protected]>2026-01-16 10:51:02 -0600
commit88fd6a294b3ad88d50cb8e1853589ee4e68dc74e (patch)
tree1b187dab4b86528430498edc3ec3d3d4a243ea5f
parentea8ef37d50408203d2ef7ebbdb82bbd15bbf8461 (diff)
downloadopencode-88fd6a294b3ad88d50cb8e1853589ee4e68dc74e.tar.gz
opencode-88fd6a294b3ad88d50cb8e1853589ee4e68dc74e.zip
feat(desktop): Terminal Splits (#8767)
-rw-r--r--packages/app/src/components/session/session-sortable-terminal-tab.tsx4
-rw-r--r--packages/app/src/components/terminal-split.tsx322
-rw-r--r--packages/app/src/components/terminal.tsx21
-rw-r--r--packages/app/src/context/terminal.tsx390
-rw-r--r--packages/app/src/index.css13
-rw-r--r--packages/app/src/pages/session.tsx46
-rw-r--r--packages/opencode/src/pty/index.ts4
7 files changed, 729 insertions, 71 deletions
diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx
index d20f587f4..654d1f9ab 100644
--- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx
+++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx
@@ -14,8 +14,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element
<Tabs.Trigger
value={props.terminal.id}
closeButton={
- terminal.all().length > 1 && (
- <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
+ terminal.tabs().length > 1 && (
+ <IconButton icon="close" variant="ghost" onClick={() => terminal.closeTab(props.terminal.tabId)} />
)
}
>
diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx
new file mode 100644
index 000000000..9a05ff22c
--- /dev/null
+++ b/packages/app/src/components/terminal-split.tsx
@@ -0,0 +1,322 @@
+import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"
+import { Terminal } from "./terminal"
+import { useTerminal, type Panel } from "@/context/terminal"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+
+export interface TerminalSplitProps {
+ tabId: string
+}
+
+function computeLayout(
+ panels: Record<string, Panel>,
+ panelId: string,
+ bounds: { top: number; left: number; width: number; height: number },
+): Map<string, { top: number; left: number; width: number; height: number }> {
+ const result = new Map<string, { top: number; left: number; width: number; height: number }>()
+ const panel = panels[panelId]
+ if (!panel) return result
+
+ if (panel.ptyId) {
+ result.set(panel.ptyId, bounds)
+ } else if (panel.children && panel.children.length === 2) {
+ const [leftId, rightId] = panel.children
+ const sizes = panel.sizes ?? [50, 50]
+
+ if (panel.direction === "horizontal") {
+ const topHeight = (bounds.height * sizes[0]) / 100
+ const topBounds = { ...bounds, height: topHeight }
+ const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bounds.height - topHeight }
+ for (const [k, v] of computeLayout(panels, leftId, topBounds)) result.set(k, v)
+ for (const [k, v] of computeLayout(panels, rightId, bottomBounds)) result.set(k, v)
+ } else {
+ const leftWidth = (bounds.width * sizes[0]) / 100
+ const leftBounds = { ...bounds, width: leftWidth }
+ const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: bounds.width - leftWidth }
+ for (const [k, v] of computeLayout(panels, leftId, leftBounds)) result.set(k, v)
+ for (const [k, v] of computeLayout(panels, rightId, rightBounds)) result.set(k, v)
+ }
+ }
+
+ return result
+}
+
+function findPanelForPty(panels: Record<string, Panel>, ptyId: string): string | undefined {
+ for (const [id, panel] of Object.entries(panels)) {
+ if (panel.ptyId === ptyId) return id
+ }
+}
+
+export function TerminalSplit(props: TerminalSplitProps) {
+ const terminal = useTerminal()
+ const pane = createMemo(() => terminal.pane(props.tabId))
+ const terminals = createMemo(() => terminal.all().filter((t) => t.tabId === props.tabId))
+ const [containerFocused, setContainerFocused] = createSignal(true)
+
+ const layout = createMemo(() => {
+ const p = pane()
+ if (!p) {
+ const single = terminals()[0]
+ if (!single) return new Map()
+ return new Map([[single.id, { top: 0, left: 0, width: 100, height: 100 }]])
+ }
+ return computeLayout(p.panels, p.root, { top: 0, left: 0, width: 100, height: 100 })
+ })
+
+ const focused = createMemo(() => {
+ const p = pane()
+ if (!p) return props.tabId
+ const focusedPanel = p.panels[p.focused ?? ""]
+ return focusedPanel?.ptyId ?? props.tabId
+ })
+
+ const handleFocus = (ptyId: string) => {
+ const p = pane()
+ if (!p) return
+ const panelId = findPanelForPty(p.panels, ptyId)
+ if (panelId) terminal.focus(props.tabId, panelId)
+ }
+
+ const handleClose = (ptyId: string) => {
+ const pty = terminal.all().find((t) => t.id === ptyId)
+ if (!pty) return
+
+ const p = pane()
+ if (!p) {
+ if (pty.tabId === props.tabId) {
+ terminal.closeTab(props.tabId)
+ }
+ return
+ }
+ const panelId = findPanelForPty(p.panels, ptyId)
+ if (panelId) terminal.closeSplit(props.tabId, panelId)
+ }
+
+ return (
+ <div
+ class="relative size-full"
+ data-terminal-split-container
+ onFocusIn={() => setContainerFocused(true)}
+ onFocusOut={(e) => {
+ const related = e.relatedTarget as Node | null
+ if (!related || !e.currentTarget.contains(related)) {
+ setContainerFocused(false)
+ }
+ }}
+ >
+ <For each={terminals()}>
+ {(pty) => {
+ const bounds = createMemo(() => layout().get(pty.id) ?? { top: 0, left: 0, width: 100, height: 100 })
+ const isFocused = createMemo(() => focused() === pty.id)
+ const hasSplits = createMemo(() => !!pane())
+
+ return (
+ <div
+ class="absolute flex flex-col min-h-0"
+ classList={{
+ "ring-1 ring-inset ring-border-strong-base": containerFocused() && isFocused(),
+ "border-l border-border-weak-base": bounds().left > 0,
+ "border-t border-border-weak-base": bounds().top > 0,
+ }}
+ style={{
+ top: `${bounds().top}%`,
+ left: `${bounds().left}%`,
+ width: `${bounds().width}%`,
+ height: `${bounds().height}%`,
+ }}
+ onClick={() => handleFocus(pty.id)}
+ >
+ <Show when={pane()}>
+ <div class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity">
+ <IconButton
+ icon="close"
+ variant="ghost"
+ onClick={(e) => {
+ e.stopPropagation()
+ handleClose(pty.id)
+ }}
+ />
+ </div>
+ </Show>
+ <div
+ class="flex-1 min-h-0"
+ classList={{ "opacity-50": !containerFocused() || (hasSplits() && !isFocused()) }}
+ >
+ <Terminal
+ pty={pty}
+ focused={isFocused()}
+ onCleanup={terminal.update}
+ onConnectError={() => terminal.clone(pty.id)}
+ onExit={() => handleClose(pty.id)}
+ class="size-full"
+ />
+ </div>
+ </div>
+ )
+ }}
+ </For>
+ <ResizeHandles tabId={props.tabId} />
+ </div>
+ )
+}
+
+function ResizeHandles(props: { tabId: string }) {
+ const terminal = useTerminal()
+ const pane = createMemo(() => terminal.pane(props.tabId))
+
+ const splits = createMemo(() => {
+ const p = pane()
+ if (!p) return []
+ return Object.values(p.panels).filter((panel) => panel.children && panel.children.length === 2)
+ })
+
+ return <For each={splits()}>{(panel) => <ResizeHandle tabId={props.tabId} panelId={panel.id} />}</For>
+}
+
+function ResizeHandle(props: { tabId: string; panelId: string }) {
+ const terminal = useTerminal()
+ const pane = createMemo(() => terminal.pane(props.tabId))
+ const panel = createMemo(() => pane()?.panels[props.panelId])
+
+ let cleanup: VoidFunction | undefined
+
+ onCleanup(() => cleanup?.())
+
+ const position = createMemo(() => {
+ const p = pane()
+ if (!p) return null
+ const pan = panel()
+ if (!pan?.children || pan.children.length !== 2) return null
+
+ const bounds = computePanelBounds(p.panels, p.root, props.panelId, {
+ top: 0,
+ left: 0,
+ width: 100,
+ height: 100,
+ })
+ if (!bounds) return null
+
+ const sizes = pan.sizes ?? [50, 50]
+
+ if (pan.direction === "horizontal") {
+ return {
+ horizontal: true,
+ top: bounds.top + (bounds.height * sizes[0]) / 100,
+ left: bounds.left,
+ size: bounds.width,
+ }
+ }
+ return {
+ horizontal: false,
+ top: bounds.top,
+ left: bounds.left + (bounds.width * sizes[0]) / 100,
+ size: bounds.height,
+ }
+ })
+
+ const handleMouseDown = (e: MouseEvent) => {
+ e.preventDefault()
+
+ const pos = position()
+ if (!pos) return
+
+ const container = (e.target as HTMLElement).closest("[data-terminal-split-container]") as HTMLElement
+ if (!container) return
+
+ const rect = container.getBoundingClientRect()
+ const pan = panel()
+ if (!pan) return
+
+ const p = pane()
+ if (!p) return
+ const panelBounds = computePanelBounds(p.panels, p.root, props.panelId, {
+ top: 0,
+ left: 0,
+ width: 100,
+ height: 100,
+ })
+ if (!panelBounds) return
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (pan.direction === "horizontal") {
+ const totalPx = (rect.height * panelBounds.height) / 100
+ const topPx = (rect.height * panelBounds.top) / 100
+ const posPx = e.clientY - rect.top - topPx
+ const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100))
+ terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent])
+ } else {
+ const totalPx = (rect.width * panelBounds.width) / 100
+ const leftPx = (rect.width * panelBounds.left) / 100
+ const posPx = e.clientX - rect.left - leftPx
+ const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100))
+ terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent])
+ }
+ }
+
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove)
+ document.removeEventListener("mouseup", handleMouseUp)
+ cleanup = undefined
+ }
+
+ cleanup = handleMouseUp
+ document.addEventListener("mousemove", handleMouseMove)
+ document.addEventListener("mouseup", handleMouseUp)
+ }
+
+ return (
+ <Show when={position()}>
+ {(pos) => (
+ <div
+ data-component="resize-handle"
+ data-direction={pos().horizontal ? "vertical" : "horizontal"}
+ class="absolute"
+ style={{
+ top: `${pos().top}%`,
+ left: `${pos().left}%`,
+ width: pos().horizontal ? `${pos().size}%` : "8px",
+ height: pos().horizontal ? "8px" : `${pos().size}%`,
+ transform: pos().horizontal ? "translateY(-50%)" : "translateX(-50%)",
+ cursor: pos().horizontal ? "row-resize" : "col-resize",
+ }}
+ onMouseDown={handleMouseDown}
+ />
+ )}
+ </Show>
+ )
+}
+
+function computePanelBounds(
+ panels: Record<string, Panel>,
+ currentId: string,
+ targetId: string,
+ bounds: { top: number; left: number; width: number; height: number },
+): { top: number; left: number; width: number; height: number } | null {
+ if (currentId === targetId) return bounds
+
+ const panel = panels[currentId]
+ if (!panel?.children || panel.children.length !== 2) return null
+
+ const [leftId, rightId] = panel.children
+ const sizes = panel.sizes ?? [50, 50]
+ const horizontal = panel.direction === "horizontal"
+
+ if (horizontal) {
+ const topHeight = (bounds.height * sizes[0]) / 100
+ const bottomHeight = bounds.height - topHeight
+ const topBounds = { ...bounds, height: topHeight }
+ const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bottomHeight }
+ return (
+ computePanelBounds(panels, leftId, targetId, topBounds) ??
+ computePanelBounds(panels, rightId, targetId, bottomBounds)
+ )
+ }
+
+ const leftWidth = (bounds.width * sizes[0]) / 100
+ const rightWidth = bounds.width - leftWidth
+ const leftBounds = { ...bounds, width: leftWidth }
+ const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: rightWidth }
+ return (
+ computePanelBounds(panels, leftId, targetId, leftBounds) ??
+ computePanelBounds(panels, rightId, targetId, rightBounds)
+ )
+}
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 8001e2caa..a37a540f1 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -7,9 +7,11 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
+ focused?: boolean
onSubmit?: () => void
onCleanup?: (pty: LocalPTY) => void
onConnectError?: (error: unknown) => void
+ onExit?: () => void
}
type TerminalColors = {
@@ -38,7 +40,7 @@ export const Terminal = (props: TerminalProps) => {
const sdk = useSDK()
const theme = useTheme()
let container!: HTMLDivElement
- const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
+ const [local, others] = splitProps(props, ["pty", "focused", "class", "classList", "onConnectError"])
let ws: WebSocket | undefined
let term: Term | undefined
let ghostty: Ghostty
@@ -49,6 +51,7 @@ export const Terminal = (props: TerminalProps) => {
let handleTextareaBlur: () => void
let reconnect: number | undefined
let disposed = false
+ let cleaning = false
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
@@ -88,6 +91,11 @@ export const Terminal = (props: TerminalProps) => {
t.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
+
+ createEffect(() => {
+ if (local.focused) focusTerminal()
+ })
+
const handlePointerDown = () => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement && activeElement !== container) {
@@ -166,6 +174,11 @@ export const Terminal = (props: TerminalProps) => {
return true
}
+ // allow cmd+d and cmd+shift+d for terminal splitting
+ if (event.metaKey && key === "d") {
+ return true
+ }
+
return false
})
@@ -231,7 +244,6 @@ export const Terminal = (props: TerminalProps) => {
// console.log("Scroll position:", ydisp)
// })
socket.addEventListener("open", () => {
- console.log("WebSocket connected")
sdk.client.pty
.update({
ptyID: local.pty.id,
@@ -250,7 +262,9 @@ export const Terminal = (props: TerminalProps) => {
props.onConnectError?.(error)
})
socket.addEventListener("close", () => {
- console.log("WebSocket disconnected")
+ if (!cleaning) {
+ props.onExit?.()
+ }
})
})
@@ -274,6 +288,7 @@ export const Terminal = (props: TerminalProps) => {
})
}
+ cleaning = true
ws?.close()
t?.dispose()
})
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index a7753069c..e1492c8da 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -9,12 +9,31 @@ export type LocalPTY = {
id: string
title: string
titleNumber: number
+ tabId: string
rows?: number
cols?: number
buffer?: string
scrollY?: number
}
+export type SplitDirection = "horizontal" | "vertical"
+
+export type Panel = {
+ id: string
+ parentId?: string
+ ptyId?: string
+ direction?: SplitDirection
+ children?: [string, string]
+ sizes?: [number, number]
+}
+
+export type TabPane = {
+ id: string
+ root: string
+ panels: Record<string, Panel>
+ focused?: string
+}
+
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
@@ -25,6 +44,10 @@ type TerminalCacheEntry = {
dispose: VoidFunction
}
+function generateId() {
+ return Math.random().toString(36).slice(2, 10)
+}
+
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
@@ -33,47 +56,102 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
createStore<{
active?: string
all: LocalPTY[]
+ panes: Record<string, TabPane>
}>({
all: [],
+ panes: {},
}),
)
- return {
- ready,
- all: createMemo(() => Object.values(store.all)),
- active: createMemo(() => store.active),
- new() {
- const existingTitleNumbers = new Set(
- store.all.map((pty) => {
- const match = pty.titleNumber
- return match
- }),
- )
+ const getNextTitleNumber = () => {
+ const existing = new Set(store.all.filter((p) => p.tabId === p.id).map((pty) => pty.titleNumber))
+ let next = 1
+ while (existing.has(next)) next++
+ return next
+ }
+
+ const createPty = async (tabId?: string): Promise<LocalPTY | undefined> => {
+ const tab = tabId ? store.all.find((p) => p.id === tabId) : undefined
+ const num = tab?.titleNumber ?? getNextTitleNumber()
+ const title = tab?.title ?? `Terminal ${num}`
+ const pty = await sdk.client.pty.create({ title }).catch((e) => {
+ console.error("Failed to create terminal", e)
+ return undefined
+ })
+ if (!pty?.data?.id) return undefined
+ return {
+ id: pty.data.id,
+ title,
+ titleNumber: num,
+ tabId: tabId ?? pty.data.id,
+ }
+ }
+
+ const getAllPtyIds = (pane: TabPane, panelId: string): string[] => {
+ const panel = pane.panels[panelId]
+ if (!panel) return []
+ if (panel.ptyId) return [panel.ptyId]
+ if (panel.children && panel.children.length === 2) {
+ return [...getAllPtyIds(pane, panel.children[0]), ...getAllPtyIds(pane, panel.children[1])]
+ }
+ return []
+ }
+
+ const getFirstLeaf = (pane: TabPane, panelId: string): string | undefined => {
+ const panel = pane.panels[panelId]
+ if (!panel) return undefined
+ if (panel.ptyId) return panelId
+ if (panel.children?.[0]) return getFirstLeaf(pane, panel.children[0])
+ return undefined
+ }
+
+ const migrate = (terminals: LocalPTY[]) =>
+ terminals.map((p) => ((p as { tabId?: string }).tabId ? p : { ...p, tabId: p.id }))
- let nextNumber = 1
- while (existingTitleNumbers.has(nextNumber)) {
- nextNumber++
+ const tabCache = new Map<string, LocalPTY>()
+ const tabs = createMemo(() => {
+ const migrated = migrate(store.all)
+ const seen = new Set<string>()
+ const result: LocalPTY[] = []
+ for (const p of migrated) {
+ if (!seen.has(p.tabId)) {
+ seen.add(p.tabId)
+ const cached = tabCache.get(p.tabId)
+ if (cached) {
+ cached.title = p.title
+ cached.titleNumber = p.titleNumber
+ result.push(cached)
+ } else {
+ const tab = { ...p, id: p.tabId }
+ tabCache.set(p.tabId, tab)
+ result.push(tab)
+ }
}
+ }
+ for (const key of tabCache.keys()) {
+ if (!seen.has(key)) tabCache.delete(key)
+ }
+ return result
+ })
+ const all = createMemo(() => migrate(store.all))
- sdk.client.pty
- .create({ title: `Terminal ${nextNumber}` })
- .then((pty) => {
- const id = pty.data?.id
- if (!id) return
- setStore("all", [
- ...store.all,
- {
- id,
- title: pty.data?.title ?? "Terminal",
- titleNumber: nextNumber,
- },
- ])
- setStore("active", id)
- })
- .catch((e) => {
- console.error("Failed to create terminal", e)
- })
+ return {
+ ready,
+ tabs,
+ all,
+ active: () => store.active,
+ panes: () => store.panes,
+ pane: (tabId: string) => store.panes[tabId],
+ panel: (tabId: string, panelId: string) => store.panes[tabId]?.panels[panelId],
+ focused: (tabId: string) => store.panes[tabId]?.focused,
+
+ async new() {
+ const pty = await createPty()
+ if (!pty) return
+ setStore("all", [...store.all, pty])
+ setStore("active", pty.tabId)
},
+
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
@@ -86,46 +164,82 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
console.error("Failed to update terminal", e)
})
},
+
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
- const clone = await sdk.client.pty
- .create({
- title: pty.title,
- })
- .catch((e) => {
- console.error("Failed to clone terminal", e)
- return undefined
- })
- if (!clone?.data) return
- setStore("all", index, {
- ...pty,
- ...clone.data,
+ const clone = await sdk.client.pty.create({ title: pty.title }).catch((e) => {
+ console.error("Failed to clone terminal", e)
+ return undefined
})
- if (store.active === pty.id) {
- setStore("active", clone.data.id)
+ if (!clone?.data) return
+ setStore("all", index, { ...pty, ...clone.data })
+ if (store.active === pty.tabId) {
+ setStore("active", pty.tabId)
}
},
+
open(id: string) {
setStore("active", id)
},
+
async close(id: string) {
- batch(() => {
- setStore(
- "all",
- store.all.filter((x) => x.id !== id),
- )
- if (store.active === id) {
- const index = store.all.findIndex((f) => f.id === id)
- const previous = store.all[Math.max(0, index - 1)]
- setStore("active", previous?.id)
+ const pty = store.all.find((x) => x.id === id)
+ if (!pty) return
+
+ const pane = store.panes[pty.tabId]
+ if (pane) {
+ const panelId = Object.keys(pane.panels).find((key) => pane.panels[key].ptyId === id)
+ if (panelId) {
+ await this.closeSplit(pty.tabId, panelId)
+ return
}
- })
+ }
+
+ if (store.active === pty.tabId) {
+ const remaining = store.all.filter((p) => p.tabId === p.id && p.id !== id)
+ setStore("active", remaining[0]?.tabId)
+ }
+
+ setStore(
+ "all",
+ store.all.filter((x) => x.id !== id),
+ )
+
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
+
+ async closeTab(tabId: string) {
+ const pane = store.panes[tabId]
+ const terminalsInTab = store.all.filter((p) => p.tabId === tabId)
+ const ptyIds = pane ? getAllPtyIds(pane, pane.root) : terminalsInTab.map((p) => p.id)
+
+ const remainingTabs = store.all.filter((p) => p.tabId !== tabId)
+ const uniqueTabIds = [...new Set(remainingTabs.map((p) => p.tabId))]
+
+ setStore(
+ "all",
+ store.all.filter((x) => !ptyIds.includes(x.id)),
+ )
+ setStore(
+ "panes",
+ produce((panes) => {
+ delete panes[tabId]
+ }),
+ )
+ if (store.active === tabId) {
+ setStore("active", uniqueTabIds[0])
+ }
+ for (const ptyId of ptyIds) {
+ await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => {
+ console.error("Failed to close terminal", e)
+ })
+ }
+ },
+
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
@@ -136,6 +250,159 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
}),
)
},
+
+ async split(tabId: string, direction: SplitDirection) {
+ const pane = store.panes[tabId]
+ const newPty = await createPty(tabId)
+ if (!newPty) return
+
+ setStore("all", [...store.all, newPty])
+
+ if (!pane) {
+ const rootId = generateId()
+ const leftId = generateId()
+ const rightId = generateId()
+
+ setStore("panes", tabId, {
+ id: tabId,
+ root: rootId,
+ panels: {
+ [rootId]: {
+ id: rootId,
+ direction,
+ children: [leftId, rightId],
+ sizes: [50, 50],
+ },
+ [leftId]: {
+ id: leftId,
+ parentId: rootId,
+ ptyId: tabId,
+ },
+ [rightId]: {
+ id: rightId,
+ parentId: rootId,
+ ptyId: newPty.id,
+ },
+ },
+ focused: rightId,
+ })
+ } else {
+ const focusedPanelId = pane.focused
+ if (!focusedPanelId) return
+
+ const focusedPanel = pane.panels[focusedPanelId]
+ if (!focusedPanel?.ptyId) return
+
+ const oldPtyId = focusedPanel.ptyId
+ const newSplitId = generateId()
+ const newTerminalId = generateId()
+
+ setStore("panes", tabId, "panels", newSplitId, {
+ id: newSplitId,
+ parentId: focusedPanelId,
+ ptyId: oldPtyId,
+ })
+ setStore("panes", tabId, "panels", newTerminalId, {
+ id: newTerminalId,
+ parentId: focusedPanelId,
+ ptyId: newPty.id,
+ })
+ setStore("panes", tabId, "panels", focusedPanelId, "ptyId", undefined)
+ setStore("panes", tabId, "panels", focusedPanelId, "direction", direction)
+ setStore("panes", tabId, "panels", focusedPanelId, "children", [newSplitId, newTerminalId])
+ setStore("panes", tabId, "panels", focusedPanelId, "sizes", [50, 50])
+ setStore("panes", tabId, "focused", newTerminalId)
+ }
+ },
+
+ focus(tabId: string, panelId: string) {
+ if (store.panes[tabId]) {
+ setStore("panes", tabId, "focused", panelId)
+ }
+ },
+
+ async closeSplit(tabId: string, panelId: string) {
+ const pane = store.panes[tabId]
+ if (!pane) return
+
+ const panel = pane.panels[panelId]
+ if (!panel) return
+
+ const ptyId = panel.ptyId
+ if (!ptyId) return
+
+ if (!panel.parentId) {
+ await this.closeTab(tabId)
+ return
+ }
+
+ const parentPanel = pane.panels[panel.parentId]
+ if (!parentPanel?.children || parentPanel.children.length !== 2) return
+
+ const siblingId = parentPanel.children[0] === panelId ? parentPanel.children[1] : parentPanel.children[0]
+ const sibling = pane.panels[siblingId]
+ if (!sibling) return
+
+ const newFocused = sibling.ptyId ? panel.parentId! : (getFirstLeaf(pane, sibling.children![0]) ?? panel.parentId!)
+
+ batch(() => {
+ setStore(
+ "panes",
+ tabId,
+ "panels",
+ produce((panels) => {
+ const parent = panels[panel.parentId!]
+ if (!parent) return
+
+ if (sibling.ptyId) {
+ parent.ptyId = sibling.ptyId
+ parent.direction = undefined
+ parent.children = undefined
+ parent.sizes = undefined
+ } else if (sibling.children && sibling.children.length === 2) {
+ parent.ptyId = undefined
+ parent.direction = sibling.direction
+ parent.children = sibling.children
+ parent.sizes = sibling.sizes
+ panels[sibling.children[0]].parentId = panel.parentId!
+ panels[sibling.children[1]].parentId = panel.parentId!
+ }
+
+ delete panels[panelId]
+ delete panels[siblingId]
+ }),
+ )
+
+ setStore("panes", tabId, "focused", newFocused)
+
+ setStore(
+ "all",
+ store.all.filter((x) => x.id !== ptyId),
+ )
+ })
+
+ const remainingPanels = Object.values(store.panes[tabId]?.panels ?? {})
+ const shouldCleanupPane = remainingPanels.length === 1 && remainingPanels[0]?.ptyId
+
+ if (shouldCleanupPane) {
+ setStore(
+ "panes",
+ produce((panes) => {
+ delete panes[tabId]
+ }),
+ )
+ }
+
+ await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => {
+ console.error("Failed to close terminal", e)
+ })
+ },
+
+ resizeSplit(tabId: string, panelId: string, sizes: [number, number]) {
+ if (store.panes[tabId]?.panels[panelId]) {
+ setStore("panes", tabId, "panels", panelId, "sizes", sizes)
+ }
+ },
}
}
@@ -189,14 +456,25 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return {
ready: () => session().ready(),
+ tabs: () => session().tabs(),
all: () => session().all(),
active: () => session().active(),
+ panes: () => session().panes(),
+ pane: (tabId: string) => session().pane(tabId),
+ panel: (tabId: string, panelId: string) => session().panel(tabId, panelId),
+ focused: (tabId: string) => session().focused(tabId),
new: () => session().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
clone: (id: string) => session().clone(id),
open: (id: string) => session().open(id),
close: (id: string) => session().close(id),
+ closeTab: (tabId: string) => session().closeTab(tabId),
move: (id: string, to: number) => session().move(id, to),
+ split: (tabId: string, direction: SplitDirection) => session().split(tabId, direction),
+ focus: (tabId: string, panelId: string) => session().focus(tabId, panelId),
+ closeSplit: (tabId: string, panelId: string) => session().closeSplit(tabId, panelId),
+ resizeSplit: (tabId: string, panelId: string, sizes: [number, number]) =>
+ session().resizeSplit(tabId, panelId, sizes),
}
},
})
diff --git a/packages/app/src/index.css b/packages/app/src/index.css
index d9d51aa8f..2326bbb11 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -9,3 +9,16 @@
*[data-tauri-drag-region] {
app-region: drag;
}
+
+/* Terminal split resize handles */
+[data-terminal-split-container] [data-component="resize-handle"] {
+ inset: unset;
+
+ &[data-direction="horizontal"] {
+ height: 100%;
+ }
+
+ &[data-direction="vertical"] {
+ width: 100%;
+ }
+}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index ca5e73a9b..585156afa 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -26,6 +26,7 @@ import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
+import { TerminalSplit } from "@/components/terminal-split"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
@@ -170,6 +171,7 @@ export default function Page() {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
+ const activeTerminal = createMemo(() => terminal.active())
if (import.meta.env.DEV) {
createEffect(
@@ -380,7 +382,7 @@ export default function Page() {
createEffect(() => {
if (!view().terminal.opened()) return
if (!terminal.ready()) return
- if (terminal.all().length !== 0) return
+ if (terminal.tabs().length !== 0) return
terminal.new()
})
@@ -460,6 +462,30 @@ export default function Page() {
onSelect: () => terminal.new(),
},
{
+ id: "terminal.split.vertical",
+ title: "Split terminal right",
+ description: "Split the current terminal vertically",
+ category: "Terminal",
+ keybind: "mod+d",
+ disabled: !terminal.active(),
+ onSelect: () => {
+ const active = terminal.active()
+ if (active) terminal.split(active, "vertical")
+ },
+ },
+ {
+ id: "terminal.split.horizontal",
+ title: "Split terminal down",
+ description: "Split the current terminal horizontally",
+ category: "Terminal",
+ keybind: "mod+shift+d",
+ disabled: !terminal.active(),
+ onSelect: () => {
+ const active = terminal.active()
+ if (active) terminal.split(active, "horizontal")
+ },
+ },
+ {
id: "steps.toggle",
title: "Toggle steps",
description: "Show or hide steps for the current message",
@@ -707,7 +733,7 @@ export default function Page() {
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
- const terminals = terminal.all()
+ const terminals = terminal.tabs()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
@@ -1009,7 +1035,7 @@ export default function Page() {
createEffect(() => {
if (!terminal.ready()) return
- handoff.terminals = terminal.all().map((t) => t.title)
+ handoff.terminals = terminal.tabs().map((t) => t.title)
})
createEffect(() => {
@@ -1666,10 +1692,10 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
- <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
+ <Tabs variant="alt" value={activeTerminal()} onChange={terminal.open}>
<Tabs.List class="h-10">
- <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
- <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
+ <SortableProvider ids={terminal.tabs().map((t: LocalPTY) => t.id)}>
+ <For each={terminal.tabs()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
@@ -1681,10 +1707,10 @@ export default function Page() {
</TooltipKeybind>
</div>
</Tabs.List>
- <For each={terminal.all()}>
+ <For each={terminal.tabs()}>
{(pty) => (
- <Tabs.Content value={pty.id}>
- <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
+ <Tabs.Content value={pty.id} class="h-[calc(100%-2.5rem)]">
+ <TerminalSplit tabId={pty.id} />
</Tabs.Content>
)}
</For>
@@ -1692,7 +1718,7 @@ export default function Page() {
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
- const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
+ const pty = createMemo(() => terminal.tabs().find((t: LocalPTY) => t.id === draggedId()))
return (
<Show when={pty()}>
{(t) => (
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts
index 39ccebf96..b76160d50 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/packages/opencode/src/pty/index.ts
@@ -146,6 +146,10 @@ export namespace Pty {
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
+ for (const ws of session.subscribers) {
+ ws.close()
+ }
+ session.subscribers.clear()
Bus.publish(Event.Exited, { id, exitCode })
state().delete(id)
})