Просмотр исходного кода

feat(desktop): Terminal Splits (#8767)

Daniel Polito 1 месяц назад
Родитель
Сommit
88fd6a294b

+ 2 - 2
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)} />
             )
           }
         >

+ 322 - 0
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)
+  )
+}

+ 18 - 3
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()
   })

+ 334 - 56
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),
     }
   },
 })

+ 13 - 0
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%;
+  }
+}

+ 36 - 10
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()
   })
 
@@ -459,6 +461,30 @@ export default function Page() {
       keybind: "ctrl+shift+`",
       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",
@@ -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) => (

+ 4 - 0
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)
     })