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