feat(tui): resize vertical tab rail (#42615)

This commit is contained in:
Kit Langton
2026-08-14 13:16:49 -04:00
committed by GitHub
parent 2ca6d8ee49
commit 23258ada86
5 changed files with 136 additions and 23 deletions
+99 -7
View File
@@ -15,6 +15,7 @@ import {
MouseButton,
type CliRenderer,
type CliRendererConfig,
type MouseEvent,
type ThemeMode,
} from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
@@ -71,7 +72,7 @@ import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { sessionTabsFitVertically } from "./ui/layout"
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { createThemeSource, ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
@@ -98,7 +99,7 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi
import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { StorageProvider, useStorage } from "./context/storage"
import { createTuiClipboard } from "./clipboard"
registerOpencodeSpinner()
@@ -471,6 +472,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const client = useClient()
const toast = useToast()
const theme = useTheme()
const tabsTheme = useTheme("elevated")
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
const data = useData()
const location = useLocation()
@@ -478,6 +480,41 @@ function App(props: { pair?: DialogPairCredentials }) {
const promptRef = usePromptRef()
const plugins = usePlugin()
const clipboard = useClipboard()
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
})
const [preferredTabsWidth, setPreferredTabsWidth] = createSignal(layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH)
const [tabsResizeHovered, setTabsResizeHovered] = createSignal(false)
const [tabsResizing, setTabsResizing] = createSignal(false)
let requestedTabsWidth = layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH
createEffect(() => {
if (tabsResizing()) return
requestedTabsWidth = layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH
setPreferredTabsWidth(requestedTabsWidth)
})
const verticalTabsWidth = () => clampSessionTabsWidth(preferredTabsWidth(), dimensions().width)
const resizeVerticalTabs = (width: number) => setPreferredTabsWidth(clampSessionTabsWidth(width, dimensions().width))
const commitVerticalTabsWidth = (width: number) => {
const next = clampSessionTabsWidth(width, dimensions().width)
setPreferredTabsWidth(next)
if (requestedTabsWidth === next) return
requestedTabsWidth = next
void updateLayout((draft) => {
draft.verticalTabsWidth = next
}).catch((error) => console.error("Failed to persist TUI layout", error))
}
let tabsResizeMoved = false
let lastTabsBoundaryClick = 0
const finishTabsResize = (event: MouseEvent) => {
if (!tabsResizing()) return
const next = tabsResizeMoved ? event.x + 1 : verticalTabsWidth()
setTabsResizing(false)
lastTabsBoundaryClick = tabsResizeMoved ? 0 : Date.now()
commitVerticalTabsWidth(next)
const width = clampSessionTabsWidth(next, dimensions().width)
setTabsResizeHovered(event.x >= width - 1 && event.x <= width)
event.stopPropagation()
}
let openingOpen: Promise<SessionInfo[]> | undefined
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
@@ -536,9 +573,11 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
const tabsVertical = () =>
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, preferredTabsWidth())
const tabsVisible = () =>
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
createEffect(() => {
renderer.useMouse = config.data.mouse
@@ -1233,9 +1272,23 @@ function App(props: { pair?: DialogPairCredentials }) {
}}
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<Show when={tabsVisible() && tabsVertical()}>
<SessionTabs orientation="vertical" />
<box
flexGrow={1}
minHeight={0}
flexDirection="row"
position="relative"
onMouseDrag={(event) => {
if (!tabsResizing()) return
tabsResizeMoved = true
lastTabsBoundaryClick = 0
resizeVerticalTabs(event.x + 1)
event.stopPropagation()
}}
onMouseDragEnd={finishTabsResize}
onMouseUp={finishTabsResize}
>
<Show when={verticalTabsVisible()}>
<SessionTabs orientation="vertical" width={verticalTabsWidth()} />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
@@ -1249,7 +1302,7 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
{(_) => <Session />}
{(_) => <Session verticalTabsWidth={verticalTabsVisible() ? verticalTabsWidth() : 0} />}
</Show>
</Match>
<Match when={route.data.type === "plugin"}>
@@ -1264,6 +1317,45 @@ function App(props: { pair?: DialogPairCredentials }) {
<Slot path="app" />
</Show>
</box>
<Show when={verticalTabsVisible()}>
<box
position="absolute"
left={verticalTabsWidth() - 1}
top={0}
zIndex={10}
width={2}
height="100%"
onMouseOver={() => setTabsResizeHovered(true)}
onMouseOut={() => setTabsResizeHovered(false)}
onMouseDown={(event) => {
if (event.button !== MouseButton.LEFT) return
const now = Date.now()
if (now - lastTabsBoundaryClick < 300) {
lastTabsBoundaryClick = 0
setTabsResizing(false)
setTabsResizeHovered(false)
commitVerticalTabsWidth(SESSION_SIDEBAR_WIDTH)
event.preventDefault()
event.stopPropagation()
return
}
tabsResizeMoved = false
setTabsResizing(true)
event.preventDefault()
event.stopPropagation()
}}
>
<box
width={1}
height="100%"
backgroundColor={
tabsResizeHovered() || tabsResizing()
? tabsTheme.background.action.primary.hovered
: tabsTheme.background.default
}
/>
</box>
</Show>
</box>
<Show when={devtools()}>
<DevToolsBar />
+4 -3
View File
@@ -273,12 +273,13 @@ export function SessionTabs(
controller?: SessionTabsController
animations?: boolean
orientation?: "horizontal" | "vertical"
width?: number
} = {},
) {
return (
<Switch>
<Match when={props.orientation === "vertical"}>
<VerticalSessionTabs controller={props.controller} animations={props.animations} />
<VerticalSessionTabs controller={props.controller} animations={props.animations} width={props.width} />
</Match>
<Match when={true}>
<HorizontalSessionTabs controller={props.controller} animations={props.animations} />
@@ -287,7 +288,7 @@ export function SessionTabs(
)
}
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) {
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean; width?: number }) {
const contextTabs = useSessionTabs()
const tabs: SessionTabsController = props.controller ?? contextTabs
const data = useData()
@@ -295,7 +296,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const width = () => SESSION_SIDEBAR_WIDTH
const width = () => props.width ?? SESSION_SIDEBAR_WIDTH
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
+3 -10
View File
@@ -68,7 +68,6 @@ import { errorMessage } from "../../util/error"
import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { projectedPromptInput } from "../../prompt/codec"
import { deduplicateVisibleImages } from "../../prompt/attachment"
import { useEpilogue } from "../../context/epilogue"
@@ -141,7 +140,7 @@ function use() {
return ctx
}
export function Session() {
export function Session(props: { verticalTabsWidth: number }) {
const setEpilogue = useEpilogue()
const clipboard = useClipboard()
const writeExport = async (file: string, content: string) => {
@@ -228,13 +227,7 @@ export function Session() {
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const availableWidth = createMemo(
() =>
dimensions().width -
(config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
? SESSION_SIDEBAR_WIDTH
: 0),
)
const availableWidth = createMemo(() => dimensions().width - props.verticalTabsWidth)
const wide = createMemo(() => availableWidth() > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
@@ -290,7 +283,7 @@ export function Session() {
createEffect(
on(
() => [dimensions().width, dimensions().height] as const,
() => [dimensions().width, dimensions().height, props.verticalTabsWidth] as const,
(_, previous) => {
if (previous) clearMessageNavigation()
},
+11 -2
View File
@@ -1,6 +1,15 @@
export const SESSION_SIDEBAR_WIDTH = 42
export const SESSION_SIDEBAR_MIN_WIDTH = 24
export const SESSION_SIDEBAR_MAX_WIDTH = 72
const SESSION_CONTENT_MIN_WIDTH = 44
export function sessionTabsFitVertically(total: number) {
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
export function sessionTabsFitVertically(total: number, width = SESSION_SIDEBAR_WIDTH) {
return total >= width + SESSION_CONTENT_MIN_WIDTH
}
export function clampSessionTabsWidth(width: number, total: number) {
return Math.max(
SESSION_SIDEBAR_MIN_WIDTH,
Math.min(width, SESSION_SIDEBAR_MAX_WIDTH, total - SESSION_CONTENT_MIN_WIDTH),
)
}
+19 -1
View File
@@ -1,8 +1,26 @@
import { expect, test } from "bun:test"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
import {
clampSessionTabsWidth,
sessionTabsFitVertically,
SESSION_SIDEBAR_MAX_WIDTH,
SESSION_SIDEBAR_MIN_WIDTH,
SESSION_SIDEBAR_WIDTH,
} from "../../src/ui/layout"
test("vertical tabs match the session sidebar and preserve compact content width", () => {
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
expect(sessionTabsFitVertically(86)).toBe(true)
expect(sessionTabsFitVertically(85)).toBe(false)
})
test("vertical tabs account for a resized width", () => {
expect(sessionTabsFitVertically(104, 60)).toBe(true)
expect(sessionTabsFitVertically(103, 60)).toBe(false)
})
test("vertical tab width preserves minimum rail and content widths", () => {
expect(clampSessionTabsWidth(10, 120)).toBe(SESSION_SIDEBAR_MIN_WIDTH)
expect(clampSessionTabsWidth(50, 120)).toBe(50)
expect(clampSessionTabsWidth(100, 120)).toBe(SESSION_SIDEBAR_MAX_WIDTH)
expect(clampSessionTabsWidth(100, 100)).toBe(56)
})