feat(tui): add automatic tabs mode (#50456)

This commit is contained in:
opencode-agent[bot]
2026-09-22 01:00:26 +00:00
committed by GitHub
parent 1814dd9799
commit 5b9dc35eec
17 changed files with 111 additions and 46 deletions
+2 -2
View File
@@ -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<Record<string, unknown>>(
(result, value) => mergeRecords(result, value ?? {}),
(result, value) => mergeRecords(result, normalizeLegacyTabs(value) ?? {}),
{},
),
),
+8
View File
@@ -8,3 +8,11 @@ export const Info = Schema.Struct({
...Config.Info.fields,
})
export type Info = Schema.Schema.Type<typeof Info>
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 }
}
+22 -2
View File
@@ -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(
+4 -5
View File
@@ -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",
+14 -4
View File
@@ -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<Info, "attention" | "cursor" | "keybinds" | "leader"
tps: boolean
}
tabs: {
mode: "auto" | "on" | "off"
enabled: boolean
scope: "global" | "cwd"
layout: "horizontal" | "vertical"
@@ -268,7 +272,12 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
}
}
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
export function resolve(
input: Info,
options: { terminalSuspend: boolean; environment?: Readonly<Record<string, string | undefined>> },
): 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<Record<string, string | undefined>> }
children: JSX.Element
}) {
const [config, setConfig] = createStore(props.config)
+10 -10
View File
@@ -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 })
@@ -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: "" }) },
@@ -28,7 +28,7 @@ test("releasing a transcript selection over tab controls does not activate them"
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
@@ -79,7 +79,7 @@ test("middle-click closes a session tab without selecting it", async () => {
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
@@ -127,7 +127,7 @@ test("keeps consecutive close controls fixed across overflow window changes", as
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
@@ -178,7 +178,7 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
@@ -33,7 +33,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
const [status, setStatus] = createSignal<SessionTabsStatus>(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<typeof useConfig>
let theme!: ReturnType<typeof useTheme>
+32 -6
View File
@@ -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({
@@ -135,7 +135,7 @@ async function renderSessionTabs(
let storage!: ReturnType<typeof useStorage>
let config!: ReturnType<typeof useConfig>
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()
+1 -1
View File
@@ -8,7 +8,7 @@ type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader"> & {
}
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 },
+1 -1
View File
@@ -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",
@@ -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: "" }) },
+1 -1
View File
@@ -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: "" }) },
+1 -1
View File
@@ -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: {
+5 -3
View File
@@ -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.
<div class="docs-table-scroll" role="region" aria-label="Tab settings" tabIndex={0}>
| 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. |