diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ebc2303507e..e8920eb1839 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -7,7 +7,7 @@ import { produce, type Draft } from "immer" import { applyEdits, modify, parse, type ParseError } from "jsonc-parser" import path from "path" import { ConfigMigration } from "./migrate" -import { Info, SchemaURL } from "./schema" +import { Info, normalizeLegacyTabs, SchemaURL } from "./schema" export * from "./schema" @@ -119,7 +119,7 @@ function merge(...values: readonly (Info | undefined)[]) { return Option.getOrElse( decode( values.reduce>( - (result, value) => mergeRecords(result, value ?? {}), + (result, value) => mergeRecords(result, normalizeLegacyTabs(value) ?? {}), {}, ), ), diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 687dd1e2224..5c9adac7b9c 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -8,3 +8,11 @@ export const Info = Schema.Struct({ ...Config.Info.fields, }) export type Info = Schema.Schema.Type + +export function normalizeLegacyTabs(info: Info | undefined) { + if (info?.tabs?.enabled === undefined) return info + const tabs = { ...info.tabs } + tabs.mode ??= tabs.enabled ? "on" : "off" + delete tabs.enabled + return { ...info, tabs } +} diff --git a/packages/cli/test/config.test.ts b/packages/cli/test/config.test.ts index d26b7b2e942..d4629eacaae 100644 --- a/packages/cli/test/config.test.ts +++ b/packages/cli/test/config.test.ts @@ -78,7 +78,7 @@ test("merges inline CLI config content over the global config", async () => { await Bun.write( file, JSON.stringify({ - tabs: { enabled: true, scope: "global" }, + tabs: { mode: "on", scope: "global" }, keybinds: { "app.exit": "ctrl+q" }, plugins: ["global"], animations: true, @@ -105,7 +105,7 @@ test("merges inline CLI config content over the global config", async () => { }), ) - expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" }) + expect(result.loaded.tabs).toEqual({ mode: "off", scope: "global" }) expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false }) expect(result.loaded.plugins).toEqual(["inline"]) expect(result.updated).toMatchObject({ animations: false, mouse: false }) @@ -116,6 +116,26 @@ test("merges inline CLI config content over the global config", async () => { } }) +test("reads the legacy tabs toggle without rewriting it", async () => { + await using directory = await tmpdir() + const file = path.join(directory.path, "cli.json") + await Bun.write(file, JSON.stringify({ tabs: { enabled: false } })) + + const config = await run( + directory.path, + Effect.gen(function* () { + const service = yield* Config.Service + expect((yield* service.get()).tabs).toEqual({ mode: "off" }) + return yield* service.update((draft) => { + draft.animations = false + }) + }), + ) + + expect(config.tabs).toEqual({ mode: "off" }) + expect(await Bun.file(file).json()).toEqual({ tabs: { enabled: false }, animations: false }) +}) + test("migrates tui and kv config into cli.json", async () => { await using directory = await tmpdir() await Bun.write( diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 209c5c2d6c2..1ff4b94d837 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -121,12 +121,11 @@ export const settings: Setting[] = [ keywords: ["approve", "accept", "permission requests"], }, { - title: "Enabled", + title: "Mode", category: "Tabs", - path: ["tabs", "enabled"], - default: true, - values: [false, true], - labels: ["off", "on"], + path: ["tabs", "mode"], + default: "auto", + values: ["off", "on", "auto"], }, { title: "Scope", diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 644948d65f2..f9b91b98a13 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -173,8 +173,11 @@ export const Info = Schema.Struct({ ).annotate({ description: "Session transcript presentation settings" }), tabs: Schema.optional( Schema.Struct({ + mode: Schema.optional(Schema.Literals(["auto", "on", "off"])).annotate({ + description: "Use session tabs always, never, or when the terminal environment supports them", + }), enabled: Schema.optional(Schema.Boolean).annotate({ - description: "Use a persistent tab strip instead of pinned quick-switch sessions", + description: "Legacy tab toggle; use mode instead", }), scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ description: "Share tabs globally or keep a separate set for each working directory", @@ -261,6 +264,7 @@ export type Resolved = Omit> }, +): Resolved { + const tabsMode = + input.tabs?.mode ?? (input.tabs?.enabled === undefined ? "auto" : input.tabs.enabled ? "on" : "off") const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds } if (!options.terminalSuspend) { keybinds["terminal.suspend"] = "none" @@ -310,7 +319,8 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res }, tabs: { ...input.tabs, - enabled: input.tabs?.enabled ?? true, + mode: tabsMode, + enabled: tabsMode === "on" || (tabsMode === "auto" && (options.environment ?? process.env).HERDR_ENV !== "1"), scope: input.tabs?.scope ?? "cwd", layout: input.tabs?.layout ?? "horizontal", indicators: input.tabs?.indicators ?? "status", @@ -327,7 +337,7 @@ const ConfigContext = createContext<{ export function ConfigProvider(props: { config: Resolved service?: Interface - options?: { terminalSuspend: boolean } + options?: { terminalSuspend: boolean; environment?: Readonly> } children: JSX.Element }) { const [config, setConfig] = createStore(props.config) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 8a95337fc24..c9a0ff44e06 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -123,7 +123,7 @@ test.each(["dismissed", "refreshing"])( const locations: string[] = [] await using setup = await createAppFixture({ state: state.path, - config: { animations: false, tabs: { enabled: false } }, + config: { animations: false, tabs: { mode: "off" } }, fetch: (url) => { if (url.pathname === "/api/session") { if (url.searchParams.has("parentID")) { @@ -447,7 +447,7 @@ test("vertical session tabs switch to horizontal below readable content width", state: state.path, config: { animations: false, - tabs: { enabled: true, layout: "vertical", indicators: "status" }, + tabs: { mode: "on", layout: "vertical", indicators: "status" }, session: { sidebar: "hide" }, }, args: { sessionID: session.id }, @@ -485,7 +485,7 @@ test("narrow vertical session tabs collapse to a compact rail with the terminal" state: state.path, config: { animations: false, - tabs: { enabled: true, layout: "vertical", indicators: "status" }, + tabs: { mode: "on", layout: "vertical", indicators: "status" }, session: { sidebar: "hide" }, }, args: { sessionID: session.id }, @@ -526,7 +526,7 @@ test("automatic rename refreshes the displayed title before settling, even witho width: 110, height: 20, state: state.path, - config: { tabs: { enabled: true, layout: "vertical" }, session: { sidebar: "hide" } }, + config: { tabs: { mode: "on", layout: "vertical" }, session: { sidebar: "hide" } }, args: { sessionID: session.id }, fetch: async (url, request) => { if (url.pathname === "/api/location") return json(location) @@ -585,7 +585,7 @@ test.each([80, 120])("completes custom Markdown and ordinary fences in a session width, height: 55, state: state.path, - config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } }, + config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } }, args: { sessionID: session.id }, fetch: (url) => { if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) @@ -690,7 +690,7 @@ test("keeps assistant footer metrics current after prepend, same-length refresh, width: 100, height: 40, state: state.path, - config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide", tps: true } }, + config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide", tps: true } }, args: { sessionID: session.id }, fetch: (url) => { if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) @@ -886,7 +886,7 @@ test.each([false, true])("uses the resolved launch directory for new prompts (fa let session: unknown await using setup = await createAppFixture({ state: state.path, - config: { animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } }, + config: { animations: false, tabs: { mode: "off" }, keybinds: { "session.new": "f6" } }, fetch: async (url, request) => { requests.push(url) if (url.searchParams.has("location[directory]") && url.searchParams.get("location[directory]") !== target) @@ -1024,7 +1024,7 @@ test("completed user shell output replaces a partial live read when the final re let failedReads = 0 await using setup = await createAppFixture({ state: state.path, - config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } }, + config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } }, args: { sessionID: session.id }, fetch: (url) => { if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) @@ -1456,7 +1456,7 @@ test.each([100, 44])( width, state: state.path, args: { sessionID: session.id }, - config: { animations: false, tabs: { enabled: false } }, + config: { animations: false, tabs: { mode: "off" } }, fetch: (url) => { if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) @@ -1620,7 +1620,7 @@ test.each([44, 100])( width, state: state.path, args: { sessionID: session.id }, - config: { animations: false, tabs: { enabled: false } }, + config: { animations: false, tabs: { mode: "off" } }, fetch: (url) => { if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) diff --git a/packages/tui/test/completion-notices.test.tsx b/packages/tui/test/completion-notices.test.tsx index 5a47c9097c4..e75a72101fc 100644 --- a/packages/tui/test/completion-notices.test.tsx +++ b/packages/tui/test/completion-notices.test.tsx @@ -89,7 +89,7 @@ test.each([40, 120])("shell completion notices do not navigate at width %s", asy app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { - get: async () => ({ animations: false, tabs: { enabled: false } }), + get: async () => ({ animations: false, tabs: { mode: "off" } }), update: async () => ({}), }, packages: { prepare: async () => ({ directory: "" }) }, @@ -170,7 +170,7 @@ test.each([40, 120])("subagent completion notices navigate to the child session app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { - get: async () => ({ animations: false, tabs: { enabled: false } }), + get: async () => ({ animations: false, tabs: { mode: "off" } }), update: async () => ({}), }, packages: { prepare: async () => ({ directory: "" }) }, diff --git a/packages/tui/test/component/session-tabs-mouse.test.tsx b/packages/tui/test/component/session-tabs-mouse.test.tsx index b2f6ec1963b..592f9cb23db 100644 --- a/packages/tui/test/component/session-tabs-mouse.test.tsx +++ b/packages/tui/test/component/session-tabs-mouse.test.tsx @@ -28,7 +28,7 @@ test("releasing a transcript selection over tab controls does not activate them" const app = await testRender( () => ( - + @@ -79,7 +79,7 @@ test("middle-click closes a session tab without selecting it", async () => { const app = await testRender( () => ( - + @@ -127,7 +127,7 @@ test("keeps consecutive close controls fixed across overflow window changes", as const app = await testRender( () => ( - + @@ -178,7 +178,7 @@ test("reflows held tabs when the pointer leaves the strip", async () => { const app = await testRender( () => ( - + diff --git a/packages/tui/test/component/session-tabs-status.test.tsx b/packages/tui/test/component/session-tabs-status.test.tsx index f9ccda102d0..8b4d5315adb 100644 --- a/packages/tui/test/component/session-tabs-status.test.tsx +++ b/packages/tui/test/component/session-tabs-status.test.tsx @@ -33,7 +33,7 @@ for (const orientation of ["horizontal", "vertical"] as const) { const [status, setStatus] = createSignal(EMPTY_SESSION_TAB_STATUS) const [active, setActive] = createSignal("second") const [newTab, setNewTab] = createSignal(false) - const settings: Info = { tabs: { enabled: true } } + const settings: Info = { tabs: { mode: "on" } } const copied: string[] = [] let config!: ReturnType let theme!: ReturnType diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 08d8df55f1b..e035716cee2 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -33,12 +33,13 @@ test("validates mini replay and work spinner settings", () => { test("validates the session tabs setting", () => { const decode = Schema.decodeUnknownSync(Info) - expect(decode({ tabs: { enabled: true, layout: "vertical", indicators: "numbers" } })).toEqual({ - tabs: { enabled: true, layout: "vertical", indicators: "numbers" }, + expect(decode({ tabs: { mode: "on", layout: "vertical", indicators: "numbers" } })).toEqual({ + tabs: { mode: "on", layout: "vertical", indicators: "numbers" }, }) expect(() => decode({ tabs: { indicators: "unknown" } })).toThrow() expect(() => decode({ tabs: { layout: true } })).toThrow() - expect(() => decode({ tabs: { enabled: "on" } })).toThrow() + expect(() => decode({ tabs: { mode: true } })).toThrow() + expect(decode({ tabs: { enabled: false } })).toEqual({ tabs: { enabled: false } }) expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } }) expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } }) expect(decode({ session: { tps: false } })).toEqual({ session: { tps: false } }) @@ -55,7 +56,7 @@ test("resolves nested config and keybind defaults", () => { diffs: { view: "split" }, debug: { devtools: true }, }, - { terminalSuspend: true }, + { terminalSuspend: true, environment: {} }, ) expect(config.leader.timeout).toBe(500) @@ -63,13 +64,38 @@ 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", indicators: "status" }) + expect(config.tabs).toEqual({ + mode: "auto", + enabled: true, + scope: "cwd", + layout: "horizontal", + indicators: "status", + }) expect(config.session.new_location).toBe("launch") expect(config.session.tps).toBe(true) }) +test("resolves automatic tabs from the terminal environment", () => { + expect(resolve({}, { terminalSuspend: true, environment: {} }).tabs.enabled).toBe(true) + expect(resolve({}, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).tabs.enabled).toBe(false) + expect( + resolve({ tabs: { mode: "on" } }, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).tabs.enabled, + ).toBe(true) + expect(resolve({ tabs: { mode: "off" } }, { terminalSuspend: true, environment: {} }).tabs.enabled).toBe(false) + expect(resolve({ tabs: { enabled: false } }, { terminalSuspend: true, environment: {} }).tabs).toMatchObject({ + mode: "off", + enabled: false, + }) + expect( + resolve({ tabs: { mode: "on", enabled: false } }, { terminalSuspend: true, environment: {} }).tabs, + ).toMatchObject({ mode: "on", enabled: true }) +}) + 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.mode")).toMatchObject({ + default: "auto", + values: ["off", "on", "auto"], + }) 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) => setting.path.join(".") === "tabs.indicators")).toMatchObject({ diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index ae4678c0514..f071dc7cf15 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -135,7 +135,7 @@ async function renderSessionTabs( let storage!: ReturnType let config!: ReturnType let configuration = { - tabs: { enabled: options?.tabsEnabled ?? true }, + tabs: { mode: options?.tabsEnabled === false ? ("off" as const) : ("on" as const) }, experimental: options?.experimental, session: { new_location: options?.newLocation ?? "launch" }, } @@ -205,7 +205,7 @@ async function renderSessionTabs( setTabsEnabled: (enabled: boolean) => config.update((draft) => { draft.tabs ??= {} - draft.tabs.enabled = enabled + draft.tabs.mode = enabled ? "on" : "off" }), async destroy() { app.renderer.destroy() diff --git a/packages/tui/test/fixture/tui-runtime.ts b/packages/tui/test/fixture/tui-runtime.ts index a25568cbda3..42be6333018 100644 --- a/packages/tui/test/fixture/tui-runtime.ts +++ b/packages/tui/test/fixture/tui-runtime.ts @@ -8,7 +8,7 @@ type ResolvedInput = Omit & { } export function createTuiResolvedConfig(input: ResolvedInput = {}, options?: { terminal?: boolean }) { - const config = resolve(input, { terminalSuspend: process.platform !== "win32" }) + const config = resolve(input, { terminalSuspend: process.platform !== "win32", environment: {} }) return { ...config, session: { ...config.session, terminal: options?.terminal ?? config.session.terminal }, diff --git a/packages/tui/test/session-home.test.tsx b/packages/tui/test/session-home.test.tsx index acf8ef0829c..1e8afa865f4 100644 --- a/packages/tui/test/session-home.test.tsx +++ b/packages/tui/test/session-home.test.tsx @@ -93,7 +93,7 @@ test.each([ config: { get: async () => ({ animations: false, - tabs: { enabled: false }, + tabs: { mode: "off" }, keybinds: { "session.line.up": "f6", "session.page.down": "f7", diff --git a/packages/tui/test/session-wheel-focus.test.tsx b/packages/tui/test/session-wheel-focus.test.tsx index 21598d6f5d3..047a186b8f4 100644 --- a/packages/tui/test/session-wheel-focus.test.tsx +++ b/packages/tui/test/session-wheel-focus.test.tsx @@ -78,7 +78,7 @@ test app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { - get: async () => ({ animations: false, tabs: { enabled: false } }), + get: async () => ({ animations: false, tabs: { mode: "off" } }), update: async () => ({}), }, packages: { prepare: async () => ({ directory: "" }) }, diff --git a/packages/tui/test/stats.view.test.tsx b/packages/tui/test/stats.view.test.tsx index 316c35a4aef..d061c1a783c 100644 --- a/packages/tui/test/stats.view.test.tsx +++ b/packages/tui/test/stats.view.test.tsx @@ -38,7 +38,7 @@ test("stats shows only this year and returns after errors or success", async () app: { name: "test", version: "test", channel: "test" }, server: { endpoint: { url: server.url.toString() } }, config: { - get: async () => ({ animations: false, tabs: { enabled: false } }), + get: async () => ({ animations: false, tabs: { mode: "off" } }), update: async () => ({}), }, packages: { prepare: async () => ({ directory: "" }) }, diff --git a/script/drive/shell-output.ts b/script/drive/shell-output.ts index 7fc1c04ab45..143647f1b79 100644 --- a/script/drive/shell-output.ts +++ b/script/drive/shell-output.ts @@ -12,7 +12,7 @@ export default OpenCodeDriver.use( keepArtifacts: true, tui: { recording: true, keypressOverlay: true, viewport: { cols: 90, rows: 30 } }, config: { autoupdate: false, username: "Demo" }, - tuiConfig: { theme: { name: "opencode", mode: "dark" }, animations: false, tabs: { enabled: false } }, + tuiConfig: { theme: { name: "opencode", mode: "dark" }, animations: false, tabs: { mode: "off" } }, project: { git: true, files: { diff --git a/services/www/src/docs/content/cli/config.mdx b/services/www/src/docs/content/cli/config.mdx index 5de203fd05f..4aef5392743 100644 --- a/services/www/src/docs/content/cli/config.mdx +++ b/services/www/src/docs/content/cli/config.mdx @@ -40,7 +40,7 @@ while the TUI is running. Set `OPENCODE_CLI_CONFIG_CONTENT` to apply CLI settings from inline JSON: ```sh -OPENCODE_CLI_CONFIG_CONTENT='{"tabs":{"enabled":false}}' opencode +OPENCODE_CLI_CONFIG_CONTENT='{"tabs":{"mode":"off"}}' opencode ``` OpenCode merges the inline settings over the global `cli.json`. Nested objects are merged, while arrays and scalar @@ -152,7 +152,7 @@ Configure the persistent session tab strip: ```json title="cli.json" { "tabs": { - "enabled": true, + "mode": "auto", "scope": "cwd", "layout": "horizontal", "indicators": "status" @@ -160,11 +160,13 @@ Configure the persistent session tab strip: } ``` +The legacy boolean `tabs.enabled` setting remains supported: `true` is `on` and `false` is `off`. OpenCode reads it without rewriting the config file. +
| Setting | Values | Description | | ----------------- | --------------------------- | ----------------------------------------------------------------------------- | -| `tabs.enabled` | boolean | Uses a persistent tab strip instead of pinned quick-switch sessions. | +| `tabs.mode` | `auto`, `on`, or `off` | Uses tabs automatically, always, or never. Auto hides them inside Herdr. | | `tabs.scope` | `cwd` or `global` | Keeps separate tabs per working directory or shares them globally. | | `tabs.layout` | `horizontal` or `vertical` | Places tabs in a horizontal strip or a vertical sidebar. | | `tabs.indicators` | `status` or `numbers` | Shows status icons or always shows tab numbers. |