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..9a05ff22c33 --- /dev/null +++ b/packages/app/src/components/terminal-split.tsx @@ -0,0 +1,322 @@ +import { For, Show, createMemo, createSignal, onCleanup } from "solid-js" +import { Terminal } from "./terminal" +import { useTerminal, type Panel } from "@/context/terminal" +import { IconButton } from "@opencode-ai/ui/icon-button" + +export interface TerminalSplitProps { + tabId: string +} + +function computeLayout( + panels: Record, + 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) { + 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, 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 ( +
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 }) + 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" + /> +
+
+ ) + }} +
+ +
+ ) +} + +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 {(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]) + + 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 ( + + {(pos) => ( +
+ )} + + ) +} + +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..a37a540f12d 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -7,9 +7,11 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY + focused?: boolean onSubmit?: () => void onCleanup?: (pty: LocalPTY) => void onConnectError?: (error: unknown) => void + onExit?: () => void } type TerminalColors = { @@ -38,7 +40,7 @@ export const Terminal = (props: TerminalProps) => { const sdk = useSDK() const theme = useTheme() let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) + const [local, others] = splitProps(props, ["pty", "focused", "class", "classList", "onConnectError"]) let ws: WebSocket | undefined let term: Term | undefined let ghostty: Ghostty @@ -49,6 +51,7 @@ export const Terminal = (props: TerminalProps) => { let handleTextareaBlur: () => void let reconnect: number | undefined let disposed = false + let cleaning = false const getTerminalColors = (): TerminalColors => { const mode = theme.mode() @@ -88,6 +91,11 @@ export const Terminal = (props: TerminalProps) => { t.focus() setTimeout(() => t.textarea?.focus(), 0) } + + createEffect(() => { + if (local.focused) focusTerminal() + }) + const handlePointerDown = () => { const activeElement = document.activeElement if (activeElement instanceof HTMLElement && activeElement !== container) { @@ -166,6 +174,11 @@ export const Terminal = (props: TerminalProps) => { return true } + // allow cmd+d and cmd+shift+d for terminal splitting + if (event.metaKey && key === "d") { + return true + } + return false }) @@ -231,7 +244,6 @@ export const Terminal = (props: TerminalProps) => { // console.log("Scroll position:", ydisp) // }) socket.addEventListener("open", () => { - console.log("WebSocket connected") sdk.client.pty .update({ ptyID: local.pty.id, @@ -250,7 +262,9 @@ export const Terminal = (props: TerminalProps) => { props.onConnectError?.(error) }) socket.addEventListener("close", () => { - console.log("WebSocket disconnected") + if (!cleaning) { + props.onExit?.() + } }) }) @@ -274,6 +288,7 @@ export const Terminal = (props: TerminalProps) => { }) } + cleaning = true ws?.close() t?.dispose() }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index a7753069cf9..e1492c8dab0 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -9,12 +9,31 @@ export type LocalPTY = { id: string title: string titleNumber: number + tabId: string rows?: number cols?: number buffer?: string scrollY?: number } +export type SplitDirection = "horizontal" | "vertical" + +export type Panel = { + id: string + parentId?: string + ptyId?: string + direction?: SplitDirection + children?: [string, string] + sizes?: [number, number] +} + +export type TabPane = { + id: string + root: string + panels: Record + focused?: string +} + const WORKSPACE_KEY = "__workspace__" const MAX_TERMINAL_SESSIONS = 20 @@ -25,6 +44,10 @@ type TerminalCacheEntry = { dispose: VoidFunction } +function generateId() { + return Math.random().toString(36).slice(2, 10) +} + function createTerminalSession(sdk: ReturnType, dir: string, id: string | undefined) { const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1` @@ -33,47 +56,102 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: createStore<{ active?: string all: LocalPTY[] + panes: Record }>({ all: [], + panes: {}, }), ) - return { - ready, - all: createMemo(() => Object.values(store.all)), - active: createMemo(() => store.active), - new() { - const existingTitleNumbers = new Set( - store.all.map((pty) => { - const match = pty.titleNumber - return match - }), - ) + const getNextTitleNumber = () => { + const existing = new Set(store.all.filter((p) => p.tabId === p.id).map((pty) => pty.titleNumber)) + let next = 1 + while (existing.has(next)) next++ + return next + } + + const createPty = async (tabId?: string): Promise => { + const tab = tabId ? store.all.find((p) => p.id === tabId) : undefined + const num = tab?.titleNumber ?? getNextTitleNumber() + const title = tab?.title ?? `Terminal ${num}` + const pty = await sdk.client.pty.create({ title }).catch((e) => { + console.error("Failed to create terminal", e) + return undefined + }) + if (!pty?.data?.id) return undefined + return { + id: pty.data.id, + title, + titleNumber: num, + tabId: tabId ?? pty.data.id, + } + } + + const getAllPtyIds = (pane: TabPane, panelId: string): string[] => { + const panel = pane.panels[panelId] + if (!panel) return [] + if (panel.ptyId) return [panel.ptyId] + if (panel.children && panel.children.length === 2) { + return [...getAllPtyIds(pane, panel.children[0]), ...getAllPtyIds(pane, panel.children[1])] + } + return [] + } + + const getFirstLeaf = (pane: TabPane, panelId: string): string | undefined => { + const panel = pane.panels[panelId] + if (!panel) return undefined + if (panel.ptyId) return panelId + if (panel.children?.[0]) return getFirstLeaf(pane, panel.children[0]) + return undefined + } + + const migrate = (terminals: LocalPTY[]) => + terminals.map((p) => ((p as { tabId?: string }).tabId ? p : { ...p, tabId: p.id })) - let nextNumber = 1 - while (existingTitleNumbers.has(nextNumber)) { - nextNumber++ + const tabCache = new Map() + 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)) - sdk.client.pty - .create({ title: `Terminal ${nextNumber}` }) - .then((pty) => { - const id = pty.data?.id - if (!id) return - setStore("all", [ - ...store.all, - { - id, - title: pty.data?.title ?? "Terminal", - titleNumber: nextNumber, - }, - ]) - setStore("active", id) - }) - .catch((e) => { - console.error("Failed to create terminal", e) - }) + return { + ready, + tabs, + all, + active: () => store.active, + panes: () => store.panes, + pane: (tabId: string) => store.panes[tabId], + panel: (tabId: string, panelId: string) => store.panes[tabId]?.panels[panelId], + focused: (tabId: string) => store.panes[tabId]?.focused, + + async new() { + const pty = await createPty() + if (!pty) return + setStore("all", [...store.all, pty]) + setStore("active", pty.tabId) }, + update(pty: Partial & { id: string }) { setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) sdk.client.pty @@ -86,46 +164,82 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: console.error("Failed to update terminal", e) }) }, + async clone(id: string) { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const clone = await sdk.client.pty - .create({ - title: pty.title, - }) - .catch((e) => { - console.error("Failed to clone terminal", e) - return undefined - }) - if (!clone?.data) return - setStore("all", index, { - ...pty, - ...clone.data, + const clone = await sdk.client.pty.create({ title: pty.title }).catch((e) => { + console.error("Failed to clone terminal", e) + return undefined }) - if (store.active === pty.id) { - setStore("active", clone.data.id) + if (!clone?.data) return + setStore("all", index, { ...pty, ...clone.data }) + if (store.active === pty.tabId) { + setStore("active", pty.tabId) } }, + open(id: string) { setStore("active", id) }, + async close(id: string) { - batch(() => { - setStore( - "all", - store.all.filter((x) => x.id !== id), - ) - if (store.active === id) { - const index = store.all.findIndex((f) => f.id === id) - const previous = store.all[Math.max(0, index - 1)] - setStore("active", previous?.id) + const pty = store.all.find((x) => x.id === id) + if (!pty) return + + const pane = store.panes[pty.tabId] + if (pane) { + const panelId = Object.keys(pane.panels).find((key) => pane.panels[key].ptyId === id) + if (panelId) { + await this.closeSplit(pty.tabId, panelId) + return } - }) + } + + if (store.active === pty.tabId) { + const remaining = store.all.filter((p) => p.tabId === p.id && p.id !== id) + setStore("active", remaining[0]?.tabId) + } + + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + await sdk.client.pty.remove({ ptyID: id }).catch((e) => { console.error("Failed to close terminal", e) }) }, + + async closeTab(tabId: string) { + const pane = store.panes[tabId] + const terminalsInTab = store.all.filter((p) => p.tabId === tabId) + const ptyIds = pane ? getAllPtyIds(pane, pane.root) : terminalsInTab.map((p) => p.id) + + const remainingTabs = store.all.filter((p) => p.tabId !== tabId) + const uniqueTabIds = [...new Set(remainingTabs.map((p) => p.tabId))] + + setStore( + "all", + store.all.filter((x) => !ptyIds.includes(x.id)), + ) + setStore( + "panes", + produce((panes) => { + delete panes[tabId] + }), + ) + if (store.active === tabId) { + setStore("active", uniqueTabIds[0]) + } + for (const ptyId of ptyIds) { + await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => { + console.error("Failed to close terminal", e) + }) + } + }, + move(id: string, to: number) { const index = store.all.findIndex((f) => f.id === id) if (index === -1) return @@ -136,6 +250,159 @@ function createTerminalSession(sdk: ReturnType, dir: string, id: }), ) }, + + async split(tabId: string, direction: SplitDirection) { + const pane = store.panes[tabId] + const newPty = await createPty(tabId) + if (!newPty) return + + setStore("all", [...store.all, newPty]) + + if (!pane) { + const rootId = generateId() + const leftId = generateId() + const rightId = generateId() + + setStore("panes", tabId, { + id: tabId, + root: rootId, + panels: { + [rootId]: { + id: rootId, + direction, + children: [leftId, rightId], + sizes: [50, 50], + }, + [leftId]: { + id: leftId, + parentId: rootId, + ptyId: tabId, + }, + [rightId]: { + id: rightId, + parentId: rootId, + ptyId: newPty.id, + }, + }, + focused: rightId, + }) + } else { + const focusedPanelId = pane.focused + if (!focusedPanelId) return + + const focusedPanel = pane.panels[focusedPanelId] + if (!focusedPanel?.ptyId) return + + const oldPtyId = focusedPanel.ptyId + const newSplitId = generateId() + const newTerminalId = generateId() + + setStore("panes", tabId, "panels", newSplitId, { + id: newSplitId, + parentId: focusedPanelId, + ptyId: oldPtyId, + }) + setStore("panes", tabId, "panels", newTerminalId, { + id: newTerminalId, + parentId: focusedPanelId, + ptyId: newPty.id, + }) + setStore("panes", tabId, "panels", focusedPanelId, "ptyId", undefined) + setStore("panes", tabId, "panels", focusedPanelId, "direction", direction) + setStore("panes", tabId, "panels", focusedPanelId, "children", [newSplitId, newTerminalId]) + setStore("panes", tabId, "panels", focusedPanelId, "sizes", [50, 50]) + setStore("panes", tabId, "focused", newTerminalId) + } + }, + + focus(tabId: string, panelId: string) { + if (store.panes[tabId]) { + setStore("panes", tabId, "focused", panelId) + } + }, + + async closeSplit(tabId: string, panelId: string) { + const pane = store.panes[tabId] + if (!pane) return + + const panel = pane.panels[panelId] + if (!panel) return + + const ptyId = panel.ptyId + if (!ptyId) return + + if (!panel.parentId) { + await this.closeTab(tabId) + return + } + + const parentPanel = pane.panels[panel.parentId] + if (!parentPanel?.children || parentPanel.children.length !== 2) return + + const siblingId = parentPanel.children[0] === panelId ? parentPanel.children[1] : parentPanel.children[0] + const sibling = pane.panels[siblingId] + if (!sibling) return + + const newFocused = sibling.ptyId ? panel.parentId! : (getFirstLeaf(pane, sibling.children![0]) ?? panel.parentId!) + + batch(() => { + setStore( + "panes", + tabId, + "panels", + produce((panels) => { + const parent = panels[panel.parentId!] + if (!parent) return + + if (sibling.ptyId) { + parent.ptyId = sibling.ptyId + parent.direction = undefined + parent.children = undefined + parent.sizes = undefined + } else if (sibling.children && sibling.children.length === 2) { + parent.ptyId = undefined + parent.direction = sibling.direction + parent.children = sibling.children + parent.sizes = sibling.sizes + panels[sibling.children[0]].parentId = panel.parentId! + panels[sibling.children[1]].parentId = panel.parentId! + } + + delete panels[panelId] + delete panels[siblingId] + }), + ) + + setStore("panes", tabId, "focused", newFocused) + + setStore( + "all", + store.all.filter((x) => x.id !== ptyId), + ) + }) + + const remainingPanels = Object.values(store.panes[tabId]?.panels ?? {}) + const shouldCleanupPane = remainingPanels.length === 1 && remainingPanels[0]?.ptyId + + if (shouldCleanupPane) { + setStore( + "panes", + produce((panes) => { + delete panes[tabId] + }), + ) + } + + await sdk.client.pty.remove({ ptyID: ptyId }).catch((e) => { + console.error("Failed to close terminal", e) + }) + }, + + resizeSplit(tabId: string, panelId: string, sizes: [number, number]) { + if (store.panes[tabId]?.panels[panelId]) { + setStore("panes", tabId, "panels", panelId, "sizes", sizes) + } + }, } } @@ -189,14 +456,25 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return { ready: () => session().ready(), + tabs: () => session().tabs(), all: () => session().all(), active: () => session().active(), + panes: () => session().panes(), + pane: (tabId: string) => session().pane(tabId), + panel: (tabId: string, panelId: string) => session().panel(tabId, panelId), + focused: (tabId: string) => session().focused(tabId), new: () => session().new(), update: (pty: Partial & { 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..2326bbb1132 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -9,3 +9,16 @@ *[data-tauri-drag-region] { app-region: drag; } + +/* Terminal split resize handles */ +[data-terminal-split-container] [data-component="resize-handle"] { + inset: unset; + + &[data-direction="horizontal"] { + height: 100%; + } + + &[data-direction="vertical"] { + width: 100%; + } +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 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..b76160d506e 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -146,6 +146,10 @@ export namespace Pty { ptyProcess.onExit(({ exitCode }) => { log.info("session exited", { id, exitCode }) session.info.status = "exited" + for (const ws of session.subscribers) { + ws.close() + } + session.subscribers.clear() Bus.publish(Event.Exited, { id, exitCode }) state().delete(id) })