Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 7bc33c255c refactor(tui): reuse tab position values 2026-08-10 13:23:07 -04:00
Kit Langton 11c24c324f feat(tui): support configurable tab positions 2026-08-10 13:07:19 -04:00
7 changed files with 69 additions and 28 deletions
+10 -4
View File
@@ -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}
>
<box flexGrow={1} minHeight={0} flexDirection="row">
<Show when={tabsVisible() && tabsVertical()}>
<Show when={tabsVisible() && tabPosition() === "left"}>
<SessionTabs orientation="vertical" />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={tabsVisible() && !tabsVertical()}>
<Show when={tabsVisible() && tabPosition() === "top"}>
<SessionTabs />
</Show>
<Switch>
@@ -1224,10 +1224,16 @@ function App(props: { pair?: DialogPairCredentials }) {
/>
</Match>
</Switch>
<Show when={tabsVisible() && tabPosition() === "bottom"}>
<SessionTabs />
</Show>
</box>
<PluginSlot name="app" input={{}} mode="all" />
</Show>
</box>
<Show when={tabsVisible() && tabPosition() === "right"}>
<SessionTabs orientation="vertical" />
</Show>
</box>
<Show when={devtools()}>
<DevToolsBar />
+6 -6
View File
@@ -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",
+7 -4
View File
@@ -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<typeof TabPosition>
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<Info, "attention" | "cursor" | "keybinds" | "leader"
tabs: {
enabled: boolean
scope: "global" | "cwd"
layout: "horizontal" | "vertical"
position: TabPosition
}
}
@@ -250,7 +253,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
...input.tabs,
enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
position: input.tabs?.position ?? "top",
},
}
}
+2 -4
View File
@@ -65,7 +65,7 @@ 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 { sessionTabSidebarWidth } from "../../ui/layout"
import { projectedPromptInput } from "../../prompt/codec"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
@@ -225,9 +225,7 @@ export function Session() {
const availableWidth = createMemo(
() =>
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(() => {
+13
View File
@@ -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
}
+11 -9
View File
@@ -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 () => {
+20 -1
View File
@@ -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)
})