Kaynağa Gözat

feat(tui): support configurable tab positions

Kit Langton 6 gün önce
ebeveyn
işleme
11c24c324f

+ 10 - 4
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}
     >
       <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 />

+ 5 - 5
packages/tui/src/component/dialog-config.tsx

@@ -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: ["top", "bottom", "left", "right"],
+    keywords: ["sidebar", "orientation", "layout"],
   },
   {
     title: "Layout",

+ 7 - 4
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<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
packages/tui/src/routes/session/index.tsx

@@ -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 - 0
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
+}

+ 8 - 5
packages/tui/test/config-v2.test.tsx

@@ -18,10 +18,11 @@ 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: { position: true } })).toThrow()
   expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
 })
 
@@ -42,13 +43,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")
+  const position = settings.find((setting) => setting.path.join(".") === "tabs.position")
+  expect(position?.default).toBe("top")
+  expect(position?.values).toEqual(["top", "bottom", "left", "right"])
 })
 
 test("provides config and its host interface", async () => {

+ 18 - 1
packages/tui/test/ui/layout.test.ts

@@ -1,8 +1,25 @@
 import { expect, test } from "bun:test"
-import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
+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", () => {
+  const positions = ["top", "bottom", "left", "right"] as const
+  expect(positions.map((position) => effectiveSessionTabPosition(position, 120))).toEqual([...positions])
+})
+
+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)
+})