From 1af8339166a9a2b8bb1604453a2b4a2e5ffbc671 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 20:03:42 -0300 Subject: [PATCH 1/9] feat(desktop): Terminal Splits --- .../session/session-sortable-terminal-tab.tsx | 4 +- .../app/src/components/terminal-split.tsx | 316 ++++++++++++++ packages/app/src/components/terminal.tsx | 13 +- packages/app/src/context/terminal.tsx | 385 ++++++++++++++---- packages/app/src/index.css | 13 + packages/app/src/pages/session.tsx | 46 ++- packages/opencode/src/pty/index.ts | 5 + 7 files changed, 699 insertions(+), 83 deletions(-) create mode 100644 packages/app/src/components/terminal-split.tsx 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 d20f587f48c..654d1f9abda 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 1 && ( - terminal.close(props.terminal.id)} /> + terminal.tabs().length > 1 && ( + 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 00000000000..f6122531aa2 --- /dev/null +++ b/packages/app/src/components/terminal-split.tsx @@ -0,0 +1,316 @@ +import { For, Show, createEffect, createMemo, createSignal } 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 +} + +// Compute the CSS position for a terminal based on the panel tree +function computeLayout( + panels: Record, + panelId: string, + bounds: { top: number; left: number; width: number; height: number }, +): Map { + const result = new Map() + const panel = panels[panelId] + if (!panel) return result + + if (panel.ptyId) { + // Terminal panel - store its bounds + result.set(panel.ptyId, bounds) + } else if (panel.children && panel.children.length === 2) { + // Split panel - divide bounds between children + 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 } + 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 rightWidth = bounds.width - leftWidth + const leftBounds = { ...bounds, width: leftWidth } + const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: rightWidth } + 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 +} + +// Find which panelId contains a given ptyId +function findPanelForPty(panels: Record, ptyId: string): string | undefined { + for (const [id, panel] of Object.entries(panels)) { + if (panel.ptyId === ptyId) return id + } + return undefined +} + +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)) + + // Compute layout for all terminals + const layout = createMemo(() => { + const p = pane() + if (!p) { + // Single terminal - full size + 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 p = pane() + if (!p) { + terminal.closeTab(props.tabId) + return + } + const panelId = findPanelForPty(p.panels, ptyId) + if (panelId) terminal.closeSplit(props.tabId, panelId) + } + + return ( +
+ + {(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 ( +
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)} + > + +
+ { + e.stopPropagation() + handleClose(pty.id) + }} + /> +
+
+
+ terminal.clone(pty.id)} + onExit={() => handleClose(pty.id)} + class="size-full" + /> +
+
+ ) + }} +
+ +
+ ) +} + +// Separate component for resize handles +function ResizeHandles(props: { tabId: string }) { + const terminal = useTerminal() + const pane = createMemo(() => terminal.pane(props.tabId)) + + // Collect all split panels that need resize handles + const splits = createMemo(() => { + const p = pane() + if (!p) return [] + return Object.values(p.panels).filter((panel) => panel.children && panel.children.length === 2) + }) + + return {(panel) => } +} + +function ResizeHandle(props: { tabId: string; panelId: string }) { + const terminal = useTerminal() + const pane = createMemo(() => terminal.pane(props.tabId)) + const panel = createMemo(() => pane()?.panels[props.panelId]) + const [dragging, setDragging] = createSignal(false) + + // Calculate the position of this resize handle + const position = createMemo(() => { + const p = pane() + if (!p) return null + const pan = panel() + if (!pan?.children || pan.children.length !== 2) return null + + // Walk up the tree to compute this panel's bounds + 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] + const horizontal = pan.direction === "horizontal" + + if (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() + setDragging(true) + + 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 + + // Get the bounds of this split panel + 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) => { + const horizontal = pan.direction === "horizontal" + if (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 = () => { + setDragging(false) + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + } + + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + } + + return ( + + {(pos) => ( +
+ )} + + ) +} + +// Helper to compute bounds of a specific panel by walking the tree +function computePanelBounds( + panels: Record, + 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 8001e2caadc..3051f1b15ed 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -10,6 +10,7 @@ export interface TerminalProps extends ComponentProps<"div"> { onSubmit?: () => void onCleanup?: (pty: LocalPTY) => void onConnectError?: (error: unknown) => void + onExit?: () => void } type TerminalColors = { @@ -49,6 +50,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() @@ -166,6 +168,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 +238,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 +256,9 @@ export const Terminal = (props: TerminalProps) => { props.onConnectError?.(error) }) socket.addEventListener("close", () => { - console.log("WebSocket disconnected") + if (!cleaning) { + props.onExit?.() + } }) }) @@ -274,6 +282,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 a7753069cf9..b7e29b0cf43 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -9,12 +9,35 @@ export type LocalPTY = { id: string title: string titleNumber: number + tabId: string rows?: number cols?: number buffer?: string scrollY?: number } +export type SplitDirection = "horizontal" | "vertical" + +// Flat panel structure - either a terminal or a split container +export type Panel = { + id: string + parentId?: string + // For terminal panels + ptyId?: string + // For split panels + direction?: SplitDirection + children?: [string, string] // panel IDs + sizes?: [number, number] +} + +// A tab's split pane state +export type TabPane = { + id: string + root: string // root panel ID + panels: Record + focused?: string // focused panel ID +} + const WORKSPACE_KEY = "__workspace__" const MAX_TERMINAL_SESSIONS = 20 @@ -25,6 +48,10 @@ type TerminalCacheEntry = { dispose: VoidFunction } +function generateId() { + return Math.random().toString(36).slice(2, 10) +} + function createTerminalSession(sdk: ReturnType, dir: string, id: string | undefined) { const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1` @@ -33,99 +60,142 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: createStore<{ active?: string all: LocalPTY[] + panes: Record }>({ all: [], + panes: {}, }), ) + const getNextTitleNumber = () => { + const existing = new Set(store.all.map((pty) => pty.titleNumber)) + let next = 1 + while (existing.has(next)) next++ + return next + } + + const createPty = async (tabId?: string): Promise => { + const num = getNextTitleNumber() + const pty = await sdk.client.pty.create({ title: `Terminal ${num}` }).catch((e) => { + console.error("Failed to create terminal", e) + return undefined + }) + if (!pty?.data?.id) return undefined + return { + id: pty.data.id, + title: pty.data.title ?? "Terminal", + titleNumber: num, + tabId: tabId ?? pty.data.id, + } + } + + // Get all ptyIds from a panel and its children + 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 [] + } + + // Migrate legacy terminals without tabId + const migrate = (terminals: LocalPTY[]) => + terminals.map((p) => ((p as { tabId?: string }).tabId ? p : { ...p, tabId: p.id })) + + const tabs = createMemo(() => migrate(store.all).filter((p) => p.tabId === p.id)) + const all = createMemo(() => migrate(store.all)) + 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 - }), - ) + 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, - 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 & { id: string }) { - setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) - sdk.client.pty - .update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - .catch((e) => { - console.error("Failed to update terminal", e) - }) + async new() { + const pty = await createPty() + if (!pty) return + setStore("all", [...store.all, pty]) + setStore("active", pty.tabId) }, + + update(pty: Partial & { id: string }) {}, + 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 + + 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 ptyIds = pane ? getAllPtyIds(pane, pane.root) : [tabId] + + // Remove all terminals in this tab + setStore( + "all", + store.all.filter((x) => !ptyIds.includes(x.id)), + ) + + // Remove pane + setStore( + "panes", + produce((panes) => { + delete panes[tabId] + }), + ) + + // Update active + if (store.active === tabId) { + const remaining = store.all.filter((p) => p.tabId === p.id && !ptyIds.includes(p.id)) + setStore("active", remaining[0]?.tabId) + } + + // Clean up PTYs on server + 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 +206,172 @@ function createTerminalSession(sdk: ReturnType, 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) { + // First split - create initial structure + 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 { + // Split existing panel + const focusedPanelId = pane.focused + if (!focusedPanelId) return + + const focusedPanel = pane.panels[focusedPanelId] + if (!focusedPanel?.ptyId) return // Can only split terminal panels + + const oldPtyId = focusedPanel.ptyId + const newSplitId = generateId() + const newTerminalId = generateId() + + // Add child panels first + setStore("panes", tabId, "panels", newSplitId, { + id: newSplitId, + parentId: focusedPanelId, + ptyId: oldPtyId, + }) + setStore("panes", tabId, "panels", newTerminalId, { + id: newTerminalId, + parentId: focusedPanelId, + ptyId: newPty.id, + }) + // Convert parent to split - update properties individually + 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 // Can only close terminal panels + + // If closing the root terminal (no parent), close the whole tab + if (!panel.parentId) { + await this.closeTab(tabId) + return + } + + const parentPanel = pane.panels[panel.parentId] + if (!parentPanel?.children || parentPanel.children.length !== 2) return + + // Find sibling + const siblingId = parentPanel.children[0] === panelId ? parentPanel.children[1] : parentPanel.children[0] + const sibling = pane.panels[siblingId] + if (!sibling) return + + batch(() => { + // Replace parent with sibling's content + if (sibling.ptyId) { + // Sibling is a terminal - parent becomes terminal + setStore("panes", tabId, "panels", panel.parentId!, { + id: panel.parentId!, + parentId: parentPanel.parentId, + ptyId: sibling.ptyId, + }) + } else if (sibling.children && sibling.children.length === 2) { + // Sibling is a split - parent inherits its split + setStore("panes", tabId, "panels", panel.parentId!, { + id: panel.parentId!, + parentId: parentPanel.parentId, + direction: sibling.direction, + children: sibling.children, + sizes: sibling.sizes, + }) + // Update children's parentId + setStore("panes", tabId, "panels", sibling.children[0], "parentId", panel.parentId!) + setStore("panes", tabId, "panels", sibling.children[1], "parentId", panel.parentId!) + } + + // Remove closed panel and sibling + setStore( + "panes", + tabId, + "panels", + produce((panels) => { + delete panels[panelId] + delete panels[siblingId] + }), + ) + + // Update focus + const newFocused = sibling.ptyId ? panel.parentId! : (sibling.children?.[0] ?? panel.parentId!) + setStore("panes", tabId, "focused", newFocused) + + // Remove terminal from all + setStore( + "all", + store.all.filter((x) => x.id !== ptyId), + ) + + // If only one terminal left, remove pane entirely + const remainingPanels = Object.values(store.panes[tabId]?.panels ?? {}) + if (remainingPanels.length === 1 && remainingPanels[0]?.ptyId === tabId) { + setStore( + "panes", + produce((panes) => { + delete panes[tabId] + }), + ) + } + }) + + // Clean up PTY on server + 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 +425,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 & { 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 d9d51aa8fbf..3673245251f 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 - override default inset positioning */ +[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 143150b929f..a9ba224d72c 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() }) @@ -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() { > - + - t.id)}> - {(pty) => } + t.id)}> + {(pty) => }
- + {(pty) => ( - - terminal.clone(pty.id)} /> + + )} @@ -1692,7 +1718,7 @@ export default function Page() { {(draggedId) => { - const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) + const pty = createMemo(() => terminal.tabs().find((t: LocalPTY) => t.id === draggedId())) return ( {(t) => ( diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 39ccebf96be..0a1b5fc740b 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -146,6 +146,11 @@ export namespace Pty { ptyProcess.onExit(({ exitCode }) => { log.info("session exited", { id, exitCode }) session.info.status = "exited" + // Close all WebSocket connections + for (const ws of session.subscribers) { + ws.close() + } + session.subscribers.clear() Bus.publish(Event.Exited, { id, exitCode }) state().delete(id) }) From 5e1d6b65cda5eef5747e9de807f6112fd441b659 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 20:10:47 -0300 Subject: [PATCH 2/9] Tweaks --- .../app/src/components/terminal-split.tsx | 33 +++------------- packages/app/src/context/terminal.tsx | 38 +++---------------- 2 files changed, 11 insertions(+), 60 deletions(-) diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx index f6122531aa2..bda1810c3b0 100644 --- a/packages/app/src/components/terminal-split.tsx +++ b/packages/app/src/components/terminal-split.tsx @@ -1,4 +1,4 @@ -import { For, Show, createEffect, createMemo, createSignal } from "solid-js" +import { For, Show, createMemo } from "solid-js" import { Terminal } from "./terminal" import { useTerminal, type Panel } from "@/context/terminal" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -7,7 +7,6 @@ export interface TerminalSplitProps { tabId: string } -// Compute the CSS position for a terminal based on the panel tree function computeLayout( panels: Record, panelId: string, @@ -18,26 +17,21 @@ function computeLayout( if (!panel) return result if (panel.ptyId) { - // Terminal panel - store its bounds result.set(panel.ptyId, bounds) } else if (panel.children && panel.children.length === 2) { - // Split panel - divide bounds between children const [leftId, rightId] = panel.children const sizes = panel.sizes ?? [50, 50] - const horizontal = panel.direction === "horizontal" - if (horizontal) { + if (panel.direction === "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 } + 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 rightWidth = bounds.width - leftWidth const leftBounds = { ...bounds, width: leftWidth } - const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: rightWidth } + 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) } @@ -46,12 +40,10 @@ function computeLayout( return result } -// Find which panelId contains a given ptyId function findPanelForPty(panels: Record, ptyId: string): string | undefined { for (const [id, panel] of Object.entries(panels)) { if (panel.ptyId === ptyId) return id } - return undefined } export function TerminalSplit(props: TerminalSplitProps) { @@ -59,11 +51,9 @@ export function TerminalSplit(props: TerminalSplitProps) { const pane = createMemo(() => terminal.pane(props.tabId)) const terminals = createMemo(() => terminal.all().filter((t) => t.tabId === props.tabId)) - // Compute layout for all terminals const layout = createMemo(() => { const p = pane() if (!p) { - // Single terminal - full size const single = terminals()[0] if (!single) return new Map() return new Map([[single.id, { top: 0, left: 0, width: 100, height: 100 }]]) @@ -149,12 +139,10 @@ export function TerminalSplit(props: TerminalSplitProps) { ) } -// Separate component for resize handles function ResizeHandles(props: { tabId: string }) { const terminal = useTerminal() const pane = createMemo(() => terminal.pane(props.tabId)) - // Collect all split panels that need resize handles const splits = createMemo(() => { const p = pane() if (!p) return [] @@ -168,16 +156,13 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { const terminal = useTerminal() const pane = createMemo(() => terminal.pane(props.tabId)) const panel = createMemo(() => pane()?.panels[props.panelId]) - const [dragging, setDragging] = createSignal(false) - // Calculate the position of this resize handle const position = createMemo(() => { const p = pane() if (!p) return null const pan = panel() if (!pan?.children || pan.children.length !== 2) return null - // Walk up the tree to compute this panel's bounds const bounds = computePanelBounds(p.panels, p.root, props.panelId, { top: 0, left: 0, @@ -187,9 +172,8 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { if (!bounds) return null const sizes = pan.sizes ?? [50, 50] - const horizontal = pan.direction === "horizontal" - if (horizontal) { + if (pan.direction === "horizontal") { return { horizontal: true, top: bounds.top + (bounds.height * sizes[0]) / 100, @@ -207,7 +191,6 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { const handleMouseDown = (e: MouseEvent) => { e.preventDefault() - setDragging(true) const pos = position() if (!pos) return @@ -219,7 +202,6 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { const pan = panel() if (!pan) return - // Get the bounds of this split panel const p = pane() if (!p) return const panelBounds = computePanelBounds(p.panels, p.root, props.panelId, { @@ -231,8 +213,7 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { if (!panelBounds) return const handleMouseMove = (e: MouseEvent) => { - const horizontal = pan.direction === "horizontal" - if (horizontal) { + 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 @@ -248,7 +229,6 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { } const handleMouseUp = () => { - setDragging(false) document.removeEventListener("mousemove", handleMouseMove) document.removeEventListener("mouseup", handleMouseUp) } @@ -278,7 +258,6 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { ) } -// Helper to compute bounds of a specific panel by walking the tree function computePanelBounds( panels: Record, currentId: string, diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index b7e29b0cf43..11314743add 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -18,24 +18,20 @@ export type LocalPTY = { export type SplitDirection = "horizontal" | "vertical" -// Flat panel structure - either a terminal or a split container export type Panel = { id: string parentId?: string - // For terminal panels ptyId?: string - // For split panels direction?: SplitDirection - children?: [string, string] // panel IDs + children?: [string, string] sizes?: [number, number] } -// A tab's split pane state export type TabPane = { id: string - root: string // root panel ID + root: string panels: Record - focused?: string // focused panel ID + focused?: string } const WORKSPACE_KEY = "__workspace__" @@ -89,7 +85,6 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: } } - // Get all ptyIds from a panel and its children const getAllPtyIds = (pane: TabPane, panelId: string): string[] => { const panel = pane.panels[panelId] if (!panel) return [] @@ -100,7 +95,6 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: return [] } - // Migrate legacy terminals without tabId const migrate = (terminals: LocalPTY[]) => terminals.map((p) => ((p as { tabId?: string }).tabId ? p : { ...p, tabId: p.id })) @@ -168,27 +162,20 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: const pane = store.panes[tabId] const ptyIds = pane ? getAllPtyIds(pane, pane.root) : [tabId] - // Remove all terminals in this tab setStore( "all", store.all.filter((x) => !ptyIds.includes(x.id)), ) - - // Remove pane setStore( "panes", produce((panes) => { delete panes[tabId] }), ) - - // Update active if (store.active === tabId) { const remaining = store.all.filter((p) => p.tabId === p.id && !ptyIds.includes(p.id)) setStore("active", remaining[0]?.tabId) } - - // Clean up PTYs on server for (const ptyId of ptyIds) { await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => { console.error("Failed to close terminal", e) @@ -215,7 +202,6 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: setStore("all", [...store.all, newPty]) if (!pane) { - // First split - create initial structure const rootId = generateId() const leftId = generateId() const rightId = generateId() @@ -244,18 +230,16 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: focused: rightId, }) } else { - // Split existing panel const focusedPanelId = pane.focused if (!focusedPanelId) return const focusedPanel = pane.panels[focusedPanelId] - if (!focusedPanel?.ptyId) return // Can only split terminal panels + if (!focusedPanel?.ptyId) return const oldPtyId = focusedPanel.ptyId const newSplitId = generateId() const newTerminalId = generateId() - // Add child panels first setStore("panes", tabId, "panels", newSplitId, { id: newSplitId, parentId: focusedPanelId, @@ -266,7 +250,6 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: parentId: focusedPanelId, ptyId: newPty.id, }) - // Convert parent to split - update properties individually setStore("panes", tabId, "panels", focusedPanelId, "ptyId", undefined) setStore("panes", tabId, "panels", focusedPanelId, "direction", direction) setStore("panes", tabId, "panels", focusedPanelId, "children", [newSplitId, newTerminalId]) @@ -289,9 +272,8 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: if (!panel) return const ptyId = panel.ptyId - if (!ptyId) return // Can only close terminal panels + if (!ptyId) return - // If closing the root terminal (no parent), close the whole tab if (!panel.parentId) { await this.closeTab(tabId) return @@ -300,22 +282,18 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: const parentPanel = pane.panels[panel.parentId] if (!parentPanel?.children || parentPanel.children.length !== 2) return - // Find sibling const siblingId = parentPanel.children[0] === panelId ? parentPanel.children[1] : parentPanel.children[0] const sibling = pane.panels[siblingId] if (!sibling) return batch(() => { - // Replace parent with sibling's content if (sibling.ptyId) { - // Sibling is a terminal - parent becomes terminal setStore("panes", tabId, "panels", panel.parentId!, { id: panel.parentId!, parentId: parentPanel.parentId, ptyId: sibling.ptyId, }) } else if (sibling.children && sibling.children.length === 2) { - // Sibling is a split - parent inherits its split setStore("panes", tabId, "panels", panel.parentId!, { id: panel.parentId!, parentId: parentPanel.parentId, @@ -323,12 +301,10 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: children: sibling.children, sizes: sibling.sizes, }) - // Update children's parentId setStore("panes", tabId, "panels", sibling.children[0], "parentId", panel.parentId!) setStore("panes", tabId, "panels", sibling.children[1], "parentId", panel.parentId!) } - // Remove closed panel and sibling setStore( "panes", tabId, @@ -339,17 +315,14 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: }), ) - // Update focus const newFocused = sibling.ptyId ? panel.parentId! : (sibling.children?.[0] ?? panel.parentId!) setStore("panes", tabId, "focused", newFocused) - // Remove terminal from all setStore( "all", store.all.filter((x) => x.id !== ptyId), ) - // If only one terminal left, remove pane entirely const remainingPanels = Object.values(store.panes[tabId]?.panels ?? {}) if (remainingPanels.length === 1 && remainingPanels[0]?.ptyId === tabId) { setStore( @@ -361,7 +334,6 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: } }) - // Clean up PTY on server await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => { console.error("Failed to close terminal", e) }) From 9813d19f0022f8610767430b55f56ffa26826169 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 20:12:46 -0300 Subject: [PATCH 3/9] Fixes --- packages/app/src/components/terminal-split.tsx | 4 ++-- packages/app/src/context/terminal.tsx | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx index bda1810c3b0..b531bb352e8 100644 --- a/packages/app/src/components/terminal-split.tsx +++ b/packages/app/src/components/terminal-split.tsx @@ -245,8 +245,8 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { data-direction={pos().horizontal ? "vertical" : "horizontal"} class="absolute" style={{ - top: pos().horizontal ? `${pos().top}%` : `${pos().top}%`, - left: pos().horizontal ? `${pos().left}%` : `${pos().left}%`, + 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%)", diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 11314743add..2b9b50a9061 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -118,7 +118,18 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: setStore("active", pty.tabId) }, - update(pty: Partial & { id: string }) {}, + update(pty: Partial & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty + .update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + .catch((e) => { + console.error("Failed to update terminal", e) + }) + }, async clone(id: string) { const index = store.all.findIndex((x) => x.id === id) From cbaf34afdb223e13a70e9f042d1d466f8f5d83b2 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 20:22:06 -0300 Subject: [PATCH 4/9] Fixes --- packages/app/src/context/terminal.tsx | 31 ++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 2b9b50a9061..695d31bddd9 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -335,7 +335,36 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: ) const remainingPanels = Object.values(store.panes[tabId]?.panels ?? {}) - if (remainingPanels.length === 1 && remainingPanels[0]?.ptyId === tabId) { + const shouldCleanupPane = remainingPanels.length === 1 && remainingPanels[0]?.ptyId + + if (ptyId === tabId) { + const remaining = store.all.filter((x) => x.tabId === tabId) + if (remaining.length > 0) { + const newRoot = remaining[0] + for (let i = 0; i < store.all.length; i++) { + if (store.all[i].tabId === tabId) { + setStore("all", i, "tabId", newRoot.id) + } + } + if (!shouldCleanupPane) { + const currentPane = store.panes[tabId] + if (currentPane) { + setStore("panes", newRoot.id, { ...currentPane, id: newRoot.id }) + setStore( + "panes", + produce((panes) => { + delete panes[tabId] + }), + ) + } + } + if (store.active === tabId) { + setStore("active", newRoot.id) + } + } + } + + if (shouldCleanupPane) { setStore( "panes", produce((panes) => { From 5e44f58b974ff30ad9d8921a7836314b207235c7 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 20:22:51 -0300 Subject: [PATCH 5/9] Tweak --- packages/opencode/src/pty/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 0a1b5fc740b..b76160d506e 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -146,7 +146,6 @@ export namespace Pty { ptyProcess.onExit(({ exitCode }) => { log.info("session exited", { id, exitCode }) session.info.status = "exited" - // Close all WebSocket connections for (const ws of session.subscribers) { ws.close() } From 26fecbfb3c48afe6b30c7c6dccd8aae631856098 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 20:32:09 -0300 Subject: [PATCH 6/9] Nice --- .../app/src/components/terminal-split.tsx | 29 ++++++++++++++++--- packages/app/src/context/terminal.tsx | 12 ++++---- packages/app/src/index.css | 2 +- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx index b531bb352e8..6485713801f 100644 --- a/packages/app/src/components/terminal-split.tsx +++ b/packages/app/src/components/terminal-split.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo } from "solid-js" +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" @@ -50,6 +50,7 @@ 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() @@ -86,7 +87,17 @@ export function TerminalSplit(props: TerminalSplitProps) { } return ( -
+
setContainerFocused(true)} + onFocusOut={(e) => { + const related = e.relatedTarget as Node | null + if (!related || !e.currentTarget.contains(related)) { + setContainerFocused(false) + } + }} + > {(pty) => { const bounds = createMemo(() => layout().get(pty.id) ?? { top: 0, left: 0, width: 100, height: 100 }) @@ -97,7 +108,7 @@ export function TerminalSplit(props: TerminalSplitProps) {
0, "border-t border-border-weak-base": bounds().top > 0, }} @@ -121,7 +132,10 @@ export function TerminalSplit(props: TerminalSplitProps) { />
-
+
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 @@ -231,8 +249,10 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { const handleMouseUp = () => { document.removeEventListener("mousemove", handleMouseMove) document.removeEventListener("mouseup", handleMouseUp) + cleanup = undefined } + cleanup = handleMouseUp document.addEventListener("mousemove", handleMouseMove) document.addEventListener("mouseup", handleMouseUp) } @@ -250,6 +270,7 @@ function ResizeHandle(props: { tabId: string; panelId: string }) { 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} /> diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 695d31bddd9..2e436464850 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -64,22 +64,24 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: ) const getNextTitleNumber = () => { - const existing = new Set(store.all.map((pty) => pty.titleNumber)) + 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 => { - const num = getNextTitleNumber() - const pty = await sdk.client.pty.create({ title: `Terminal ${num}` }).catch((e) => { + 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: pty.data.title ?? "Terminal", + title, titleNumber: num, tabId: tabId ?? pty.data.id, } @@ -172,6 +174,7 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: async closeTab(tabId: string) { const pane = store.panes[tabId] const ptyIds = pane ? getAllPtyIds(pane, pane.root) : [tabId] + const remaining = store.all.filter((p) => p.tabId === p.id && !ptyIds.includes(p.id)) setStore( "all", @@ -184,7 +187,6 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: }), ) if (store.active === tabId) { - const remaining = store.all.filter((p) => p.tabId === p.id && !ptyIds.includes(p.id)) setStore("active", remaining[0]?.tabId) } for (const ptyId of ptyIds) { diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 3673245251f..2326bbb1132 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -10,7 +10,7 @@ app-region: drag; } -/* Terminal split resize handles - override default inset positioning */ +/* Terminal split resize handles */ [data-terminal-split-container] [data-component="resize-handle"] { inset: unset; From 7c75af24fef115924f30370470248761d99cac80 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 21:15:05 -0300 Subject: [PATCH 7/9] Fixes --- .../app/src/components/terminal-split.tsx | 7 +- packages/app/src/context/terminal.tsx | 118 +++++++++--------- 2 files changed, 64 insertions(+), 61 deletions(-) diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx index 6485713801f..55bf0be92a7 100644 --- a/packages/app/src/components/terminal-split.tsx +++ b/packages/app/src/components/terminal-split.tsx @@ -77,9 +77,14 @@ export function TerminalSplit(props: TerminalSplitProps) { } const handleClose = (ptyId: string) => { + const pty = terminal.all().find((t) => t.id === ptyId) + if (!pty) return + const p = pane() if (!p) { - terminal.closeTab(props.tabId) + if (pty.tabId === props.tabId) { + terminal.closeTab(props.tabId) + } return } const panelId = findPanelForPty(p.panels, ptyId) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 2e436464850..d18ee2649e7 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -100,7 +100,31 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: const migrate = (terminals: LocalPTY[]) => terminals.map((p) => ((p as { tabId?: string }).tabId ? p : { ...p, tabId: p.id })) - const tabs = createMemo(() => migrate(store.all).filter((p) => p.tabId === p.id)) + const tabCache = new Map() + const tabs = createMemo(() => { + const migrated = migrate(store.all) + const seen = new Set() + 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 { @@ -173,8 +197,8 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: async closeTab(tabId: string) { const pane = store.panes[tabId] - const ptyIds = pane ? getAllPtyIds(pane, pane.root) : [tabId] - const remaining = store.all.filter((p) => p.tabId === p.id && !ptyIds.includes(p.id)) + const terminalsInTab = store.all.filter((p) => p.tabId === tabId) + const ptyIds = pane ? getAllPtyIds(pane, pane.root) : terminalsInTab.map((p) => p.id) setStore( "all", @@ -187,7 +211,9 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: }), ) if (store.active === tabId) { - setStore("active", remaining[0]?.tabId) + const remainingTabs = store.all.filter((p) => p.tabId !== tabId) + const uniqueTabIds = [...new Set(remainingTabs.map((p) => p.tabId))] + setStore("active", uniqueTabIds[0]) } for (const ptyId of ptyIds) { await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => { @@ -300,29 +326,28 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: if (!sibling) return batch(() => { - if (sibling.ptyId) { - setStore("panes", tabId, "panels", panel.parentId!, { - id: panel.parentId!, - parentId: parentPanel.parentId, - ptyId: sibling.ptyId, - }) - } else if (sibling.children && sibling.children.length === 2) { - setStore("panes", tabId, "panels", panel.parentId!, { - id: panel.parentId!, - parentId: parentPanel.parentId, - direction: sibling.direction, - children: sibling.children, - sizes: sibling.sizes, - }) - setStore("panes", tabId, "panels", sibling.children[0], "parentId", panel.parentId!) - setStore("panes", tabId, "panels", sibling.children[1], "parentId", panel.parentId!) - } - 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] }), @@ -335,46 +360,19 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: "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 (ptyId === tabId) { - const remaining = store.all.filter((x) => x.tabId === tabId) - if (remaining.length > 0) { - const newRoot = remaining[0] - for (let i = 0; i < store.all.length; i++) { - if (store.all[i].tabId === tabId) { - setStore("all", i, "tabId", newRoot.id) - } - } - if (!shouldCleanupPane) { - const currentPane = store.panes[tabId] - if (currentPane) { - setStore("panes", newRoot.id, { ...currentPane, id: newRoot.id }) - setStore( - "panes", - produce((panes) => { - delete panes[tabId] - }), - ) - } - } - if (store.active === tabId) { - setStore("active", newRoot.id) - } - } - } + 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] - }), - ) - } - }) + 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) From cda269f7d6738fad6aa7dfe4a834694e984dc59c Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 21:28:10 -0300 Subject: [PATCH 8/9] Tweaks --- packages/app/src/components/terminal-split.tsx | 1 + packages/app/src/components/terminal.tsx | 8 +++++++- packages/app/src/context/terminal.tsx | 12 +++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx index 55bf0be92a7..9a05ff22c33 100644 --- a/packages/app/src/components/terminal-split.tsx +++ b/packages/app/src/components/terminal-split.tsx @@ -143,6 +143,7 @@ export function TerminalSplit(props: TerminalSplitProps) { > terminal.clone(pty.id)} onExit={() => handleClose(pty.id)} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 3051f1b15ed..a37a540f12d 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -7,6 +7,7 @@ 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 @@ -39,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 @@ -90,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) { diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index d18ee2649e7..fae0f44a4ca 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -97,6 +97,14 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: 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 })) @@ -353,7 +361,9 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: }), ) - const newFocused = sibling.ptyId ? panel.parentId! : (sibling.children?.[0] ?? panel.parentId!) + const newFocused = sibling.ptyId + ? panel.parentId! + : (getFirstLeaf(pane, sibling.children![0]) ?? panel.parentId!) setStore("panes", tabId, "focused", newFocused) setStore( From 0ab653ddca83893eda417612b8d09b71b7aff45b Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 15 Jan 2026 21:32:12 -0300 Subject: [PATCH 9/9] Tweaks --- packages/app/src/context/terminal.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index fae0f44a4ca..e1492c8dab0 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -188,6 +188,15 @@ function createTerminalSession(sdk: ReturnType, dir: string, 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) @@ -208,6 +217,9 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: 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)), @@ -219,8 +231,6 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: }), ) if (store.active === tabId) { - const remainingTabs = store.all.filter((p) => p.tabId !== tabId) - const uniqueTabIds = [...new Set(remainingTabs.map((p) => p.tabId))] setStore("active", uniqueTabIds[0]) } for (const ptyId of ptyIds) { @@ -333,6 +343,8 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: const sibling = pane.panels[siblingId] if (!sibling) return + const newFocused = sibling.ptyId ? panel.parentId! : (getFirstLeaf(pane, sibling.children![0]) ?? panel.parentId!) + batch(() => { setStore( "panes", @@ -361,9 +373,6 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: }), ) - const newFocused = sibling.ptyId - ? panel.parentId! - : (getFirstLeaf(pane, sibling.children![0]) ?? panel.parentId!) setStore("panes", tabId, "focused", newFocused) setStore(