feat(tui): enable cwd-scoped session tabs by default

This commit is contained in:
Dax Raad 2026-08-06 02:27:38 -04:00
parent 5cf97f1b96
commit d35ca49c31
5 changed files with 30 additions and 13 deletions

View file

@ -88,7 +88,7 @@ export const settings: Setting[] = [
title: "Enabled",
category: "Tabs",
path: ["tabs", "enabled"],
default: false,
default: true,
values: [false, true],
labels: ["off", "on"],
},
@ -96,7 +96,7 @@ export const settings: Setting[] = [
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "global",
default: "cwd",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},

View file

@ -179,7 +179,7 @@ export const Info = Schema.Struct({
})
export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"> & {
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse" | "tabs"> & {
attention: {
enabled: boolean
notifications: boolean
@ -191,6 +191,11 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse">
keybinds: TuiKeybind.BindingLookupView
leader: { timeout: number }
mouse: boolean
tabs: {
enabled: boolean
scope: "global" | "cwd"
vertical?: boolean
}
}
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
@ -221,6 +226,11 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
}),
leader: { timeout: input.leader?.timeout ?? 2000 },
mouse: input.mouse ?? true,
tabs: {
...input.tabs,
enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd",
},
}
}

View file

@ -49,7 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const event = useEvent()
const config = useConfig().data
const paths = useTuiPaths()
const enabled = () => config.tabs?.enabled ?? false
const enabled = () => config.tabs.enabled
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
// mutating in place, which per-row animations and drag state depend on.
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
@ -66,12 +66,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
let closedTabs: ClosedSessionTab[] = []
function state() {
if (config.tabs?.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback
return store.global
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs?.scope ?? "global"
const scope = config.tabs.scope
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
(error) => console.error("Failed to persist session tabs", error),

View file

@ -3,6 +3,7 @@ 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"
test("validates mini replay settings", () => {
const decode = Schema.decodeUnknownSync(Info)
@ -38,6 +39,12 @@ 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" })
})
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")
})
test("provides config and its host interface", async () => {

View file

@ -60,8 +60,8 @@ async function renderSessionTabs(
await Bun.write(
file,
JSON.stringify({
global: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} },
cwd: {},
global: { tabs: [], unread: {} },
cwd: { [directory]: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} } },
}),
)
}
@ -153,15 +153,15 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("stores session tabs globally by default", async () => {
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")
try {
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
expect(await Bun.file(file).json()).toEqual({
global: { tabs: [{ sessionID: "first" }], unread: {} },
cwd: {},
global: { tabs: [], unread: {} },
cwd: { [directory]: { tabs: [{ sessionID: "first" }], unread: {} } },
})
} finally {
setup.destroy()
@ -180,7 +180,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
await titled.data.session.sync("shared")
await wait(async () => {
if (!(await Bun.file(file).exists())) return false
return (await Bun.file(file).json()).global.tabs[0]?.title === "Generated title"
return (await Bun.file(file).json()).cwd[directory]?.tabs[0]?.title === "Generated title"
})
const observed = ["Generated title"]
const pending = new Set<Promise<void>>()
@ -189,7 +189,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
const read = Bun.file(file)
.json()
.then((value) => {
const title = value.global.tabs[0]?.title
const title = value.cwd[directory]?.tabs[0]?.title
if (title && observed.at(-1) !== title) observed.push(title)
})
.catch(() => undefined)