diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c4837c9f20b0..c8d08159395b 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -70,7 +70,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 { effectiveSessionTabPosition } from "./ui/layout" import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme, useThemes } from "./context/theme" import { Home } from "./routes/home" @@ -513,7 +513,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.layout === "vertical" && sessionTabsFitVertically(dimensions().width) + const tabPosition = () => effectiveSessionTabPosition(config.data.tabs.position, dimensions().width) const tabsVisible = () => sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin" @@ -1198,13 +1198,13 @@ function App(props: { pair?: DialogPairCredentials }) { onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined} > - + - + @@ -1224,10 +1224,16 @@ function App(props: { pair?: DialogPairCredentials }) { /> + + + + + + diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 2cbc6c64527d..d63d4e646d1a 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -1,5 +1,5 @@ import { createMemo, createSignal } from "solid-js" -import { useConfig } from "../config" +import { TabPosition, useConfig } from "../config" import { useThemes } from "../context/theme" import { DialogSelect } from "../ui/dialog-select" import { useToast } from "../ui/toast" @@ -101,12 +101,12 @@ export const settings: Setting[] = [ labels: ["current directory", "global"], }, { - title: "Layout", + title: "Position", category: "Tabs", - path: ["tabs", "layout"], - default: "horizontal", - values: ["horizontal", "vertical"], - keywords: ["sidebar", "orientation", "left"], + path: ["tabs", "position"], + default: "top", + values: TabPosition.literals, + keywords: ["sidebar", "orientation", "layout"], }, { title: "Layout", diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index bfbf89077050..d6181468a61f 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -44,6 +44,9 @@ export const Cursor = Schema.Struct({ }), }).annotate({ description: "Terminal cursor settings" }) +export const TabPosition = Schema.Literals(["top", "bottom", "left", "right"]) +export type TabPosition = Schema.Schema.Type + export const Info = Schema.Struct({ theme: Schema.optional( Schema.Struct({ @@ -141,8 +144,8 @@ export const Info = Schema.Struct({ scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ description: "Share tabs globally or keep a separate set for each working directory", }), - layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({ - description: "Show tabs in a horizontal strip or vertical sidebar", + position: Schema.optional(TabPosition).annotate({ + description: "Show tabs along the top, bottom, left, or right edge", }), }), ).annotate({ description: "Tab strip settings" }), @@ -208,7 +211,7 @@ export type Resolved = Omit dimensions().width - - (config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width) - ? SESSION_SIDEBAR_WIDTH - : 0), + (config.tabs?.enabled ? sessionTabSidebarWidth(config.tabs.position, dimensions().width) : 0), ) const wide = createMemo(() => availableWidth() > 120) const sidebarVisible = createMemo(() => { diff --git a/packages/tui/src/ui/layout.ts b/packages/tui/src/ui/layout.ts index 2fa311619fae..1ebcacbfbd6b 100644 --- a/packages/tui/src/ui/layout.ts +++ b/packages/tui/src/ui/layout.ts @@ -1,6 +1,19 @@ +import type { TabPosition } from "../config" + 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 } + +export function effectiveSessionTabPosition(position: TabPosition, total: number): TabPosition { + if ((position === "left" || position === "right") && !sessionTabsFitVertically(total)) return "top" + return position +} + +export function sessionTabSidebarWidth(position: TabPosition, total: number) { + const effective = effectiveSessionTabPosition(position, total) + if (effective === "left" || effective === "right") return SESSION_SIDEBAR_WIDTH + return 0 +} diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index a22c2904dd4f..0357ffb7084b 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -2,8 +2,8 @@ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" import { Schema } from "effect" -import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config" -import { settings } from "../src/component/dialog-config" +import { resolve, ConfigProvider, Info, TabPosition, useConfig, type Interface } from "../src/config" +import { settingID, settings } from "../src/component/dialog-config" test("validates mini replay settings", () => { const decode = Schema.decodeUnknownSync(Info) @@ -18,10 +18,10 @@ test("validates mini replay settings", () => { test("validates the session tabs setting", () => { const decode = Schema.decodeUnknownSync(Info) - expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({ - tabs: { enabled: true, layout: "vertical" }, + expect(decode({ tabs: { enabled: true, position: "right" } })).toEqual({ + tabs: { enabled: true, position: "right" }, }) - expect(() => decode({ tabs: { layout: true } })).toThrow() + expect(() => decode({ tabs: { position: "vertical" } })).toThrow() expect(() => decode({ tabs: { enabled: "on" } })).toThrow() }) @@ -42,13 +42,15 @@ test("resolves nested config and keybind defaults", () => { expect(config.scroll).toEqual({ speed: 2, acceleration: true }) expect(config.diffs).toEqual({ view: "split" }) expect(config.debug).toEqual({ devtools: true }) - expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" }) + expect(config.tabs).toEqual({ enabled: true, scope: "cwd", position: "top" }) }) test("shows resolved tab defaults in settings", () => { - expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true) - expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd") - expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal") + expect(settings.find((setting) => settingID(setting) === "tabs.enabled")?.default).toBe(true) + expect(settings.find((setting) => settingID(setting) === "tabs.scope")?.default).toBe("cwd") + const position = settings.find((setting) => settingID(setting) === "tabs.position") + expect(position?.default).toBe("top") + expect(position?.values).toBe(TabPosition.literals) }) test("provides config and its host interface", async () => { diff --git a/packages/tui/test/ui/layout.test.ts b/packages/tui/test/ui/layout.test.ts index 53c9a824799b..a46d4cef439e 100644 --- a/packages/tui/test/ui/layout.test.ts +++ b/packages/tui/test/ui/layout.test.ts @@ -1,8 +1,27 @@ import { expect, test } from "bun:test" -import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout" +import { TabPosition } from "../../src/config" +import { + effectiveSessionTabPosition, + SESSION_SIDEBAR_WIDTH, + sessionTabSidebarWidth, + sessionTabsFitVertically, +} 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("preserves all tab positions when they fit", () => { + expect(TabPosition.literals.map((position) => effectiveSessionTabPosition(position, 120))).toEqual([ + ...TabPosition.literals, + ]) +}) + +test("falls side tabs back to the top strip when narrow", () => { + expect(effectiveSessionTabPosition("left", 85)).toBe("top") + expect(effectiveSessionTabPosition("right", 85)).toBe("top") + expect(sessionTabSidebarWidth("left", 85)).toBe(0) + expect(sessionTabSidebarWidth("right", 86)).toBe(SESSION_SIDEBAR_WIDTH) +})