feat(tui): resize terminal panes (#45979)

This commit is contained in:
James Long
2026-08-28 13:47:50 -04:00
committed by GitHub
Unverified
parent 0d6232ffef
commit cd3b12c579
4 changed files with 67 additions and 10 deletions
+50 -9
View File
@@ -1,4 +1,4 @@
import { RGBA } from "@opentui/core"
import { RGBA, type MouseEvent } from "@opentui/core"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
import { useConfig } from "../config"
@@ -6,9 +6,12 @@ import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useSessionTerminals } from "../context/session-terminals"
import { usePromptRef } from "../context/prompt"
import { useStorage } from "../context/storage"
import { Session } from "../routes/session"
import { Sidebar } from "../routes/session/sidebar"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { createPaneResize } from "../ui/pane-resize"
import { PaneResizeHandle } from "../ui/pane-resize-handle"
import { useToast } from "../ui/toast"
import { TerminalPane } from "./terminal-pane"
@@ -20,6 +23,32 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
const toast = useToast()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
const terminalResize = createPaneResize({
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
defaultValue: defaultTerminalWidth,
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
fromMouse: (event) => dimensions().width - event.x - 1,
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
onCommit: (width) => {
void updateLayout((draft) => {
draft.terminalWidth = width
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let resizeRelease = false
const finishTerminalResize = (event: MouseEvent) => {
if (terminalResize.resizing()) {
// A captured drag-end can be followed by mouse-up on the focus overlay.
resizeRelease = true
queueMicrotask(() => {
resizeRelease = false
})
}
terminalResize.onMouseUp(event)
}
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [sessionWidth, setSessionWidth] = createSignal<number>()
const [terminalFocused, setTerminalFocused] = createSignal(false)
@@ -96,7 +125,16 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
}))
return (
<box flexGrow={1} minWidth={0} minHeight={0} flexDirection="row" position="relative">
<box
flexGrow={1}
minWidth={0}
minHeight={0}
flexDirection="row"
position="relative"
onMouseDrag={terminalResize.onMouseDrag}
onMouseDragEnd={finishTerminalResize}
onMouseUp={finishTerminalResize}
>
<box
flexGrow={1}
flexBasis={0}
@@ -124,18 +162,17 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
height="100%"
zIndex={1}
// Consume the release before revealing permission buttons underneath.
onMouseUp={focusSession}
onMouseUp={() => {
if (terminalResize.resizing() || resizeRelease) return
focusSession()
}}
/>
</Show>
</box>
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
<box
flexShrink={0}
width={
rightPane() === "terminal"
? Math.max(1, Math.floor((dimensions().width - props.verticalTabsWidth) / 2))
: SESSION_SIDEBAR_WIDTH
}
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
minWidth={0}
minHeight={0}
>
@@ -146,6 +183,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
{(ptyID) => (
<TerminalPane
ptyID={ptyID}
resizing={terminalResize.resizing()}
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
onAutoFocus={() => {
sessions.clearFocus(ptyID)
@@ -163,6 +201,9 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
</Show>
</box>
</Show>
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
<PaneResizeHandle resize={terminalResize} left={availableWidth() - terminalResize.size() - 1} highlight="right" />
</Show>
<Show when={rightPane() === "sidebar" && !wide()}>
<box
position="absolute"
@@ -23,6 +23,7 @@ type StreamItem =
export function TerminalPane(props: {
ptyID: string
resizing?: boolean
autoFocus?: boolean
onAutoFocus?: () => void
onFocusRequest?: (focus: (() => void) | undefined) => void
@@ -77,6 +78,10 @@ export function TerminalPane(props: {
send(interactionFrame(size))
}
createEffect(() => {
if (props.resizing) interact()
})
const sendInput = (data: Uint8Array) => {
if (!restored) {
pendingInput.push(data)
+6
View File
@@ -13,3 +13,9 @@ export function clampSessionTabsWidth(width: number, total: number) {
Math.min(width, SESSION_SIDEBAR_MAX_WIDTH, total - SESSION_CONTENT_MIN_WIDTH),
)
}
export function clampTerminalPaneWidth(width: number, total: number) {
const half = Math.max(1, Math.floor(total / 2))
// Preserve the equal split when there is not enough room for both pane minima.
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
}
+6 -1
View File
@@ -1,7 +1,11 @@
import { useTheme } from "../context/theme"
import type { createPaneResize } from "./pane-resize"
export function PaneResizeHandle(props: { resize: ReturnType<typeof createPaneResize>; left: number }) {
export function PaneResizeHandle(props: {
resize: ReturnType<typeof createPaneResize>
left: number
highlight?: "left" | "right"
}) {
const theme = useTheme("elevated")
return (
@@ -19,6 +23,7 @@ export function PaneResizeHandle(props: { resize: ReturnType<typeof createPaneRe
<box
width={1}
height="100%"
marginLeft={props.highlight === "right" ? 1 : 0}
backgroundColor={
props.resize.hovered() || props.resize.resizing() ? theme.background.action.primary.hovered : undefined
}