summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-16 14:03:13 -0600
committerAdam <[email protected]>2026-01-16 14:03:13 -0600
commit71306cbd1f0b59f450fafaa6beccbcc161002692 (patch)
tree0f520b651d7549a35800aeb81857f01bccd76b2c
parent086603494691fa832239d79bf844880f87f1299c (diff)
downloadopencode-71306cbd1f0b59f450fafaa6beccbcc161002692.tar.gz
opencode-71306cbd1f0b59f450fafaa6beccbcc161002692.zip
Revert "feat(desktop): Terminal Splits (#8767)"
This reverts commit 88fd6a294b3ad88d50cb8e1853589ee4e68dc74e.
-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, 71 insertions, 729 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 654d1f9ab..d20f587f4 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.tabs().length > 1 && (
- <IconButton icon="close" variant="ghost" onClick={() => terminal.closeTab(props.terminal.tabId)} />
+ terminal.all().length > 1 && (
+ <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
)
}
>
diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx
deleted file mode 100644
index 9a05ff22c..000000000
--- a/packages/app/src/components/terminal-split.tsx
+++ /dev/null
@@ -1,322 +0,0 @@
-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 a37a540f1..8001e2caa 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -7,11 +7,9 @@ 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 = {
@@ -40,7 +38,7 @@ export const Terminal = (props: TerminalProps) => {
const sdk = useSDK()
const theme = useTheme()
let container!: HTMLDivElement
- const [local, others] = splitProps(props, ["pty", "focused", "class", "classList", "onConnectError"])
+ const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket | undefined
let term: Term | undefined
let ghostty: Ghostty
@@ -51,7 +49,6 @@ 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()
@@ -91,11 +88,6 @@ 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) {
@@ -174,11 +166,6 @@ 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
})
@@ -244,6 +231,7 @@ 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,
@@ -262,9 +250,7 @@ export const Terminal = (props: TerminalProps) => {
props.onConnectError?.(error)
})
socket.addEventListener("close", () => {
- if (!cleaning) {
- props.onExit?.()
- }
+ console.log("WebSocket disconnected")
})
})
@@ -288,7 +274,6 @@ 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 e1492c8da..a7753069c 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -9,31 +9,12 @@ 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
@@ -44,10 +25,6 @@ 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`
@@ -56,102 +33,47 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id:
createStore<{
active?: string
all: LocalPTY[]
- panes: Record<string, TabPane>
}>({
all: [],
- panes: {},
}),
)
- 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 }))
-
- 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))
-
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,
+ 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
+ }),
+ )
- async new() {
- const pty = await createPty()
- if (!pty) return
- setStore("all", [...store.all, pty])
- setStore("active", pty.tabId)
- },
+ let nextNumber = 1
+ while (existingTitleNumbers.has(nextNumber)) {
+ nextNumber++
+ }
+ 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)
+ })
+ },
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
@@ -164,82 +86,46 @@ 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
- })
+ 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 })
- if (store.active === pty.tabId) {
- setStore("active", pty.tabId)
+ setStore("all", index, {
+ ...pty,
+ ...clone.data,
+ })
+ if (store.active === pty.id) {
+ setStore("active", clone.data.id)
}
},
-
open(id: string) {
setStore("active", id)
},
-
async close(id: string) {
- 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
+ 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)
}
- }
-
- 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
@@ -250,159 +136,6 @@ 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)
- }
- },
}
}
@@ -456,25 +189,14 @@ 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 2326bbb11..d9d51aa8f 100644
--- a/packages/app/src/index.css
+++ b/packages/app/src/index.css
@@ -9,16 +9,3 @@
*[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 585156afa..ca5e73a9b 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -26,7 +26,6 @@ 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"
@@ -171,7 +170,6 @@ 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(
@@ -382,7 +380,7 @@ export default function Page() {
createEffect(() => {
if (!view().terminal.opened()) return
if (!terminal.ready()) return
- if (terminal.tabs().length !== 0) return
+ if (terminal.all().length !== 0) return
terminal.new()
})
@@ -462,30 +460,6 @@ 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",
@@ -733,7 +707,7 @@ export default function Page() {
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
- const terminals = terminal.tabs()
+ const terminals = terminal.all()
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) {
@@ -1035,7 +1009,7 @@ export default function Page() {
createEffect(() => {
if (!terminal.ready()) return
- handoff.terminals = terminal.tabs().map((t) => t.title)
+ handoff.terminals = terminal.all().map((t) => t.title)
})
createEffect(() => {
@@ -1692,10 +1666,10 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
- <Tabs variant="alt" value={activeTerminal()} onChange={terminal.open}>
+ <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
<Tabs.List class="h-10">
- <SortableProvider ids={terminal.tabs().map((t: LocalPTY) => t.id)}>
- <For each={terminal.tabs()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
+ <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
+ <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
@@ -1707,10 +1681,10 @@ export default function Page() {
</TooltipKeybind>
</div>
</Tabs.List>
- <For each={terminal.tabs()}>
+ <For each={terminal.all()}>
{(pty) => (
- <Tabs.Content value={pty.id} class="h-[calc(100%-2.5rem)]">
- <TerminalSplit tabId={pty.id} />
+ <Tabs.Content value={pty.id}>
+ <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Tabs.Content>
)}
</For>
@@ -1718,7 +1692,7 @@ export default function Page() {
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
- const pty = createMemo(() => terminal.tabs().find((t: LocalPTY) => t.id === draggedId()))
+ const pty = createMemo(() => terminal.all().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 b76160d50..39ccebf96 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/packages/opencode/src/pty/index.ts
@@ -146,10 +146,6 @@ 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)
})