From f158abd694dc48b531d7f168d3250d161863f1ee Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 1 Aug 2026 14:06:13 -0400 Subject: [PATCH] fix(tui): make vertical tabs responsive (#40080) --- packages/tui/src/app.tsx | 3 ++- packages/tui/src/component/session-tabs.tsx | 5 ++--- packages/tui/src/context/session-tabs-model.ts | 5 ----- packages/tui/src/routes/session/index.tsx | 6 ++++-- packages/tui/src/routes/session/sidebar.tsx | 3 ++- packages/tui/src/ui/layout.ts | 6 ++++++ packages/tui/test/context/session-tabs-model.test.ts | 7 ------- packages/tui/test/ui/layout.test.ts | 8 ++++++++ 8 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 packages/tui/src/ui/layout.ts create mode 100644 packages/tui/test/ui/layout.test.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index aa14a2ef0a..78733756bd 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -68,6 +68,7 @@ import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" import { DialogOpen } from "./component/dialog-open" import { SessionTabs } from "./component/session-tabs" +import { sessionTabsFitVertically } from "./ui/layout" import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme, useThemes } from "./context/theme" import { Home } from "./routes/home" @@ -519,7 +520,7 @@ 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?.vertical ?? false + const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width) const tabsVisible = () => sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin" diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 5d2e0b9b27..186466e3dd 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -12,7 +12,6 @@ import { sessionTabComplete, seedSessionTabMotion, sessionTabOverflowWidth, - sessionTabVerticalWidth, type SessionTab, type SessionTabUnread, } from "../context/session-tabs-model" @@ -21,6 +20,7 @@ import { Locale } from "../util/locale" import { stringWidth } from "../util/string-width" import { TabPulse, unreadGlowIntensity } from "./tab-pulse" import { tint } from "../theme/color" +import { SESSION_SIDEBAR_WIDTH } from "../ui/layout" import { projectName } from "../util/project" // A long title fades out over its last cells instead of cutting hard. @@ -56,12 +56,11 @@ export function SessionTabs( function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) { const tabs = props.controller ?? useSessionTabs() const data = useData() - const dimensions = useTerminalDimensions() const theme = useTheme("elevated") const { mode } = useThemes() const config = useConfig().data const animations = () => props.animations ?? config.animations ?? true - const width = () => sessionTabVerticalWidth(dimensions().width) + const width = () => SESSION_SIDEBAR_WIDTH const hueStep = () => (mode() === "light" ? 800 : 200) const accent = () => theme.hue.accent[hueStep()] const activeNumber = () => theme.hue.interactive[hueStep()] diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index 9e10336c1f..3380743118 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -19,11 +19,6 @@ export function sessionTabComplete(unread: SessionTabUnread | undefined, busy: b export const SESSION_TAB_WIDTH = 22 export const SESSION_TAB_MAX_WIDTH = 32 export const SESSION_TAB_MIN_WIDTH = 8 -export const SESSION_TAB_VERTICAL_WIDTH = 30 -export const SESSION_TAB_VERTICAL_MIN_WIDTH = 20 -export function sessionTabVerticalWidth(total: number) { - return Math.min(SESSION_TAB_VERTICAL_WIDTH, Math.max(SESSION_TAB_VERTICAL_MIN_WIDTH, Math.floor(total * 0.3))) -} // Overflow markers reserve one gap cell beside the arrow and count, e.g. "‹12 " and " 12›". export const sessionTabOverflowWidth = (count: number) => String(count).length + 2 diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 31cfe2e2cd..2f667ca0f2 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -64,7 +64,7 @@ import { errorMessage } from "../../util/error" import { useToast } from "../../ui/toast" import stripAnsi from "strip-ansi" import { usePromptRef } from "../../context/prompt" -import { sessionTabVerticalWidth } from "../../context/session-tabs-model" +import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout" import { projectedPromptInput } from "../../prompt/codec" import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" @@ -201,7 +201,9 @@ export function Session() { const groupExploration = createMemo(() => config.session?.grouping !== "none") const tabRailWidth = createMemo(() => - config.tabs?.enabled && config.tabs.vertical ? sessionTabVerticalWidth(dimensions().width) : 0, + config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width) + ? SESSION_SIDEBAR_WIDTH + : 0, ) const wide = createMemo(() => dimensions().width - tabRailWidth() > 120) const sidebarVisible = createMemo(() => { diff --git a/packages/tui/src/routes/session/sidebar.tsx b/packages/tui/src/routes/session/sidebar.tsx index 8dedbf8015..e12b524c94 100644 --- a/packages/tui/src/routes/session/sidebar.tsx +++ b/packages/tui/src/routes/session/sidebar.tsx @@ -6,6 +6,7 @@ import { PluginSlot } from "../../plugin/render" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" import { getScrollAcceleration } from "../../util/scroll" +import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const data = useData() @@ -18,7 +19,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { = SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH +} diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index 8ef8bc5b34..173ae570ae 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -12,16 +12,9 @@ import { seedSessionTabMotion, sessionTabComplete, sessionTabOverflowWidth, - sessionTabVerticalWidth, } from "../../src/context/session-tabs-model" describe("session tabs", () => { - test("keeps the vertical rail compact while preserving narrow-terminal content", () => { - expect(sessionTabVerticalWidth(140)).toBe(30) - expect(sessionTabVerticalWidth(90)).toBe(27) - expect(sessionTabVerticalWidth(60)).toBe(20) - }) - test("moves a tab to a clamped index and returns the same tabs for no-ops", () => { const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID })) expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"]) diff --git a/packages/tui/test/ui/layout.test.ts b/packages/tui/test/ui/layout.test.ts new file mode 100644 index 0000000000..53c9a82479 --- /dev/null +++ b/packages/tui/test/ui/layout.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test" +import { sessionTabsFitVertically, 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) +})