fix(tui): make vertical tabs responsive (#40080)

This commit is contained in:
Kit Langton 2026-08-01 14:06:13 -04:00 committed by GitHub
commit f158abd694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 24 additions and 19 deletions

View file

@ -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"

View file

@ -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()]

View file

@ -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

View file

@ -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(() => {

View file

@ -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 }) {
<Show when={session()}>
<box
backgroundColor={theme.background.default}
width={42}
width={SESSION_SIDEBAR_WIDTH}
height="100%"
paddingTop={1}
paddingBottom={1}

View file

@ -0,0 +1,6 @@
export const SESSION_SIDEBAR_WIDTH = 42
const SESSION_CONTENT_MIN_WIDTH = 44
export function sessionTabsFitVertically(total: number) {
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
}

View file

@ -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"])

View file

@ -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)
})