diff options
| author | Daniel Polito <[email protected]> | 2026-01-16 13:51:02 -0300 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-16 10:51:02 -0600 |
| commit | 88fd6a294b3ad88d50cb8e1853589ee4e68dc74e (patch) | |
| tree | 1b187dab4b86528430498edc3ec3d3d4a243ea5f /packages/app/src/components | |
| parent | ea8ef37d50408203d2ef7ebbdb82bbd15bbf8461 (diff) | |
| download | opencode-88fd6a294b3ad88d50cb8e1853589ee4e68dc74e.tar.gz opencode-88fd6a294b3ad88d50cb8e1853589ee4e68dc74e.zip | |
feat(desktop): Terminal Splits (#8767)
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/session/session-sortable-terminal-tab.tsx | 4 | ||||
| -rw-r--r-- | packages/app/src/components/terminal-split.tsx | 322 | ||||
| -rw-r--r-- | packages/app/src/components/terminal.tsx | 21 |
3 files changed, 342 insertions, 5 deletions
diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index d20f587f4..654d1f9ab 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -14,8 +14,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element <Tabs.Trigger value={props.terminal.id} closeButton={ - terminal.all().length > 1 && ( - <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} /> + terminal.tabs().length > 1 && ( + <IconButton icon="close" variant="ghost" onClick={() => terminal.closeTab(props.terminal.tabId)} /> ) } > diff --git a/packages/app/src/components/terminal-split.tsx b/packages/app/src/components/terminal-split.tsx new file mode 100644 index 000000000..9a05ff22c --- /dev/null +++ b/packages/app/src/components/terminal-split.tsx @@ -0,0 +1,322 @@ +import { For, Show, createMemo, createSignal, onCleanup } from "solid-js" +import { Terminal } from "./terminal" +import { useTerminal, type Panel } from "@/context/terminal" +import { IconButton } from "@opencode-ai/ui/icon-button" + +export interface TerminalSplitProps { + tabId: string +} + +function computeLayout( + panels: Record<string, Panel>, + panelId: string, + bounds: { top: number; left: number; width: number; height: number }, +): Map<string, { top: number; left: number; width: number; height: number }> { + const result = new Map<string, { top: number; left: number; width: number; height: number }>() + const panel = panels[panelId] + if (!panel) return result + + if (panel.ptyId) { + result.set(panel.ptyId, bounds) + } else if (panel.children && panel.children.length === 2) { + const [leftId, rightId] = panel.children + const sizes = panel.sizes ?? [50, 50] + + if (panel.direction === "horizontal") { + const topHeight = (bounds.height * sizes[0]) / 100 + const topBounds = { ...bounds, height: topHeight } + const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bounds.height - topHeight } + for (const [k, v] of computeLayout(panels, leftId, topBounds)) result.set(k, v) + for (const [k, v] of computeLayout(panels, rightId, bottomBounds)) result.set(k, v) + } else { + const leftWidth = (bounds.width * sizes[0]) / 100 + const leftBounds = { ...bounds, width: leftWidth } + const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: bounds.width - leftWidth } + for (const [k, v] of computeLayout(panels, leftId, leftBounds)) result.set(k, v) + for (const [k, v] of computeLayout(panels, rightId, rightBounds)) result.set(k, v) + } + } + + return result +} + +function findPanelForPty(panels: Record<string, Panel>, ptyId: string): string | undefined { + for (const [id, panel] of Object.entries(panels)) { + if (panel.ptyId === ptyId) return id + } +} + +export function TerminalSplit(props: TerminalSplitProps) { + const terminal = useTerminal() + const pane = createMemo(() => terminal.pane(props.tabId)) + const terminals = createMemo(() => terminal.all().filter((t) => t.tabId === props.tabId)) + const [containerFocused, setContainerFocused] = createSignal(true) + + const layout = createMemo(() => { + const p = pane() + if (!p) { + const single = terminals()[0] + if (!single) return new Map() + return new Map([[single.id, { top: 0, left: 0, width: 100, height: 100 }]]) + } + return computeLayout(p.panels, p.root, { top: 0, left: 0, width: 100, height: 100 }) + }) + + const focused = createMemo(() => { + const p = pane() + if (!p) return props.tabId + const focusedPanel = p.panels[p.focused ?? ""] + return focusedPanel?.ptyId ?? props.tabId + }) + + const handleFocus = (ptyId: string) => { + const p = pane() + if (!p) return + const panelId = findPanelForPty(p.panels, ptyId) + if (panelId) terminal.focus(props.tabId, panelId) + } + + const handleClose = (ptyId: string) => { + const pty = terminal.all().find((t) => t.id === ptyId) + if (!pty) return + + const p = pane() + if (!p) { + if (pty.tabId === props.tabId) { + terminal.closeTab(props.tabId) + } + return + } + const panelId = findPanelForPty(p.panels, ptyId) + if (panelId) terminal.closeSplit(props.tabId, panelId) + } + + return ( + <div + class="relative size-full" + data-terminal-split-container + onFocusIn={() => setContainerFocused(true)} + onFocusOut={(e) => { + const related = e.relatedTarget as Node | null + if (!related || !e.currentTarget.contains(related)) { + setContainerFocused(false) + } + }} + > + <For each={terminals()}> + {(pty) => { + const bounds = createMemo(() => layout().get(pty.id) ?? { top: 0, left: 0, width: 100, height: 100 }) + const isFocused = createMemo(() => focused() === pty.id) + const hasSplits = createMemo(() => !!pane()) + + return ( + <div + class="absolute flex flex-col min-h-0" + classList={{ + "ring-1 ring-inset ring-border-strong-base": containerFocused() && isFocused(), + "border-l border-border-weak-base": bounds().left > 0, + "border-t border-border-weak-base": bounds().top > 0, + }} + style={{ + top: `${bounds().top}%`, + left: `${bounds().left}%`, + width: `${bounds().width}%`, + height: `${bounds().height}%`, + }} + onClick={() => handleFocus(pty.id)} + > + <Show when={pane()}> + <div class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity"> + <IconButton + icon="close" + variant="ghost" + onClick={(e) => { + e.stopPropagation() + handleClose(pty.id) + }} + /> + </div> + </Show> + <div + class="flex-1 min-h-0" + classList={{ "opacity-50": !containerFocused() || (hasSplits() && !isFocused()) }} + > + <Terminal + pty={pty} + focused={isFocused()} + onCleanup={terminal.update} + onConnectError={() => terminal.clone(pty.id)} + onExit={() => handleClose(pty.id)} + class="size-full" + /> + </div> + </div> + ) + }} + </For> + <ResizeHandles tabId={props.tabId} /> + </div> + ) +} + +function ResizeHandles(props: { tabId: string }) { + const terminal = useTerminal() + const pane = createMemo(() => terminal.pane(props.tabId)) + + const splits = createMemo(() => { + const p = pane() + if (!p) return [] + return Object.values(p.panels).filter((panel) => panel.children && panel.children.length === 2) + }) + + return <For each={splits()}>{(panel) => <ResizeHandle tabId={props.tabId} panelId={panel.id} />}</For> +} + +function ResizeHandle(props: { tabId: string; panelId: string }) { + const terminal = useTerminal() + const pane = createMemo(() => terminal.pane(props.tabId)) + const panel = createMemo(() => pane()?.panels[props.panelId]) + + let cleanup: VoidFunction | undefined + + onCleanup(() => cleanup?.()) + + const position = createMemo(() => { + const p = pane() + if (!p) return null + const pan = panel() + if (!pan?.children || pan.children.length !== 2) return null + + const bounds = computePanelBounds(p.panels, p.root, props.panelId, { + top: 0, + left: 0, + width: 100, + height: 100, + }) + if (!bounds) return null + + const sizes = pan.sizes ?? [50, 50] + + if (pan.direction === "horizontal") { + return { + horizontal: true, + top: bounds.top + (bounds.height * sizes[0]) / 100, + left: bounds.left, + size: bounds.width, + } + } + return { + horizontal: false, + top: bounds.top, + left: bounds.left + (bounds.width * sizes[0]) / 100, + size: bounds.height, + } + }) + + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault() + + const pos = position() + if (!pos) return + + const container = (e.target as HTMLElement).closest("[data-terminal-split-container]") as HTMLElement + if (!container) return + + const rect = container.getBoundingClientRect() + const pan = panel() + if (!pan) return + + const p = pane() + if (!p) return + const panelBounds = computePanelBounds(p.panels, p.root, props.panelId, { + top: 0, + left: 0, + width: 100, + height: 100, + }) + if (!panelBounds) return + + const handleMouseMove = (e: MouseEvent) => { + if (pan.direction === "horizontal") { + const totalPx = (rect.height * panelBounds.height) / 100 + const topPx = (rect.height * panelBounds.top) / 100 + const posPx = e.clientY - rect.top - topPx + const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100)) + terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent]) + } else { + const totalPx = (rect.width * panelBounds.width) / 100 + const leftPx = (rect.width * panelBounds.left) / 100 + const posPx = e.clientX - rect.left - leftPx + const percent = Math.max(10, Math.min(90, (posPx / totalPx) * 100)) + terminal.resizeSplit(props.tabId, props.panelId, [percent, 100 - percent]) + } + } + + const handleMouseUp = () => { + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + cleanup = undefined + } + + cleanup = handleMouseUp + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + } + + return ( + <Show when={position()}> + {(pos) => ( + <div + data-component="resize-handle" + data-direction={pos().horizontal ? "vertical" : "horizontal"} + class="absolute" + style={{ + top: `${pos().top}%`, + left: `${pos().left}%`, + width: pos().horizontal ? `${pos().size}%` : "8px", + height: pos().horizontal ? "8px" : `${pos().size}%`, + transform: pos().horizontal ? "translateY(-50%)" : "translateX(-50%)", + cursor: pos().horizontal ? "row-resize" : "col-resize", + }} + onMouseDown={handleMouseDown} + /> + )} + </Show> + ) +} + +function computePanelBounds( + panels: Record<string, Panel>, + currentId: string, + targetId: string, + bounds: { top: number; left: number; width: number; height: number }, +): { top: number; left: number; width: number; height: number } | null { + if (currentId === targetId) return bounds + + const panel = panels[currentId] + if (!panel?.children || panel.children.length !== 2) return null + + const [leftId, rightId] = panel.children + const sizes = panel.sizes ?? [50, 50] + const horizontal = panel.direction === "horizontal" + + if (horizontal) { + const topHeight = (bounds.height * sizes[0]) / 100 + const bottomHeight = bounds.height - topHeight + const topBounds = { ...bounds, height: topHeight } + const bottomBounds = { ...bounds, top: bounds.top + topHeight, height: bottomHeight } + return ( + computePanelBounds(panels, leftId, targetId, topBounds) ?? + computePanelBounds(panels, rightId, targetId, bottomBounds) + ) + } + + const leftWidth = (bounds.width * sizes[0]) / 100 + const rightWidth = bounds.width - leftWidth + const leftBounds = { ...bounds, width: leftWidth } + const rightBounds = { ...bounds, left: bounds.left + leftWidth, width: rightWidth } + return ( + computePanelBounds(panels, leftId, targetId, leftBounds) ?? + computePanelBounds(panels, rightId, targetId, rightBounds) + ) +} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 8001e2caa..a37a540f1 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -7,9 +7,11 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY + focused?: boolean onSubmit?: () => void onCleanup?: (pty: LocalPTY) => void onConnectError?: (error: unknown) => void + onExit?: () => void } type TerminalColors = { @@ -38,7 +40,7 @@ export const Terminal = (props: TerminalProps) => { const sdk = useSDK() const theme = useTheme() let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) + const [local, others] = splitProps(props, ["pty", "focused", "class", "classList", "onConnectError"]) let ws: WebSocket | undefined let term: Term | undefined let ghostty: Ghostty @@ -49,6 +51,7 @@ export const Terminal = (props: TerminalProps) => { let handleTextareaBlur: () => void let reconnect: number | undefined let disposed = false + let cleaning = false const getTerminalColors = (): TerminalColors => { const mode = theme.mode() @@ -88,6 +91,11 @@ export const Terminal = (props: TerminalProps) => { t.focus() setTimeout(() => t.textarea?.focus(), 0) } + + createEffect(() => { + if (local.focused) focusTerminal() + }) + const handlePointerDown = () => { const activeElement = document.activeElement if (activeElement instanceof HTMLElement && activeElement !== container) { @@ -166,6 +174,11 @@ export const Terminal = (props: TerminalProps) => { return true } + // allow cmd+d and cmd+shift+d for terminal splitting + if (event.metaKey && key === "d") { + return true + } + return false }) @@ -231,7 +244,6 @@ export const Terminal = (props: TerminalProps) => { // console.log("Scroll position:", ydisp) // }) socket.addEventListener("open", () => { - console.log("WebSocket connected") sdk.client.pty .update({ ptyID: local.pty.id, @@ -250,7 +262,9 @@ export const Terminal = (props: TerminalProps) => { props.onConnectError?.(error) }) socket.addEventListener("close", () => { - console.log("WebSocket disconnected") + if (!cleaning) { + props.onExit?.() + } }) }) @@ -274,6 +288,7 @@ export const Terminal = (props: TerminalProps) => { }) } + cleaning = true ws?.close() t?.dispose() }) |
