Skip to content

Commit 5b9dc35

Browse files
feat(tui): add automatic tabs mode (#50456)
1 parent 1814dd9 commit 5b9dc35

17 files changed

Lines changed: 111 additions & 46 deletions

‎packages/cli/src/config/config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
77
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
88
import path from "path"
99
import { ConfigMigration } from "./migrate"
10-
import { Info, SchemaURL } from "./schema"
10+
import { Info, normalizeLegacyTabs, SchemaURL } from "./schema"
1111

1212
export * from "./schema"
1313

@@ -119,7 +119,7 @@ function merge(...values: readonly (Info | undefined)[]) {
119119
return Option.getOrElse(
120120
decode(
121121
values.reduce<Record<string, unknown>>(
122-
(result, value) => mergeRecords(result, value ?? {}),
122+
(result, value) => mergeRecords(result, normalizeLegacyTabs(value) ?? {}),
123123
{},
124124
),
125125
),

‎packages/cli/src/config/schema.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,11 @@ export const Info = Schema.Struct({
88
...Config.Info.fields,
99
})
1010
export type Info = Schema.Schema.Type<typeof Info>
11+
12+
export function normalizeLegacyTabs(info: Info | undefined) {
13+
if (info?.tabs?.enabled === undefined) return info
14+
const tabs = { ...info.tabs }
15+
tabs.mode ??= tabs.enabled ? "on" : "off"
16+
delete tabs.enabled
17+
return { ...info, tabs }
18+
}

‎packages/cli/test/config.test.ts‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ test("merges inline CLI config content over the global config", async () => {
7878
await Bun.write(
7979
file,
8080
JSON.stringify({
81-
tabs: { enabled: true, scope: "global" },
81+
tabs: { mode: "on", scope: "global" },
8282
keybinds: { "app.exit": "ctrl+q" },
8383
plugins: ["global"],
8484
animations: true,
@@ -105,7 +105,7 @@ test("merges inline CLI config content over the global config", async () => {
105105
}),
106106
)
107107

108-
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
108+
expect(result.loaded.tabs).toEqual({ mode: "off", scope: "global" })
109109
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
110110
expect(result.loaded.plugins).toEqual(["inline"])
111111
expect(result.updated).toMatchObject({ animations: false, mouse: false })
@@ -116,6 +116,26 @@ test("merges inline CLI config content over the global config", async () => {
116116
}
117117
})
118118

119+
test("reads the legacy tabs toggle without rewriting it", async () => {
120+
await using directory = await tmpdir()
121+
const file = path.join(directory.path, "cli.json")
122+
await Bun.write(file, JSON.stringify({ tabs: { enabled: false } }))
123+
124+
const config = await run(
125+
directory.path,
126+
Effect.gen(function* () {
127+
const service = yield* Config.Service
128+
expect((yield* service.get()).tabs).toEqual({ mode: "off" })
129+
return yield* service.update((draft) => {
130+
draft.animations = false
131+
})
132+
}),
133+
)
134+
135+
expect(config.tabs).toEqual({ mode: "off" })
136+
expect(await Bun.file(file).json()).toEqual({ tabs: { enabled: false }, animations: false })
137+
})
138+
119139
test("migrates tui and kv config into cli.json", async () => {
120140
await using directory = await tmpdir()
121141
await Bun.write(

‎packages/tui/src/component/dialog-config.tsx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,11 @@ export const settings: Setting[] = [
121121
keywords: ["approve", "accept", "permission requests"],
122122
},
123123
{
124-
title: "Enabled",
124+
title: "Mode",
125125
category: "Tabs",
126-
path: ["tabs", "enabled"],
127-
default: true,
128-
values: [false, true],
129-
labels: ["off", "on"],
126+
path: ["tabs", "mode"],
127+
default: "auto",
128+
values: ["off", "on", "auto"],
130129
},
131130
{
132131
title: "Scope",

‎packages/tui/src/config/index.tsx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,11 @@ export const Info = Schema.Struct({
173173
).annotate({ description: "Session transcript presentation settings" }),
174174
tabs: Schema.optional(
175175
Schema.Struct({
176+
mode: Schema.optional(Schema.Literals(["auto", "on", "off"])).annotate({
177+
description: "Use session tabs always, never, or when the terminal environment supports them",
178+
}),
176179
enabled: Schema.optional(Schema.Boolean).annotate({
177-
description: "Use a persistent tab strip instead of pinned quick-switch sessions",
180+
description: "Legacy tab toggle; use mode instead",
178181
}),
179182
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
180183
description: "Share tabs globally or keep a separate set for each working directory",
@@ -261,14 +264,20 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
261264
tps: boolean
262265
}
263266
tabs: {
267+
mode: "auto" | "on" | "off"
264268
enabled: boolean
265269
scope: "global" | "cwd"
266270
layout: "horizontal" | "vertical"
267271
indicators: "status" | "numbers"
268272
}
269273
}
270274

271-
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
275+
export function resolve(
276+
input: Info,
277+
options: { terminalSuspend: boolean; environment?: Readonly<Record<string, string | undefined>> },
278+
): Resolved {
279+
const tabsMode =
280+
input.tabs?.mode ?? (input.tabs?.enabled === undefined ? "auto" : input.tabs.enabled ? "on" : "off")
272281
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
273282
if (!options.terminalSuspend) {
274283
keybinds["terminal.suspend"] = "none"
@@ -310,7 +319,8 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
310319
},
311320
tabs: {
312321
...input.tabs,
313-
enabled: input.tabs?.enabled ?? true,
322+
mode: tabsMode,
323+
enabled: tabsMode === "on" || (tabsMode === "auto" && (options.environment ?? process.env).HERDR_ENV !== "1"),
314324
scope: input.tabs?.scope ?? "cwd",
315325
layout: input.tabs?.layout ?? "horizontal",
316326
indicators: input.tabs?.indicators ?? "status",
@@ -327,7 +337,7 @@ const ConfigContext = createContext<{
327337
export function ConfigProvider(props: {
328338
config: Resolved
329339
service?: Interface
330-
options?: { terminalSuspend: boolean }
340+
options?: { terminalSuspend: boolean; environment?: Readonly<Record<string, string | undefined>> }
331341
children: JSX.Element
332342
}) {
333343
const [config, setConfig] = createStore(props.config)

‎packages/tui/test/app-lifecycle.test.tsx‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ test.each(["dismissed", "refreshing"])(
123123
const locations: string[] = []
124124
await using setup = await createAppFixture({
125125
state: state.path,
126-
config: { animations: false, tabs: { enabled: false } },
126+
config: { animations: false, tabs: { mode: "off" } },
127127
fetch: (url) => {
128128
if (url.pathname === "/api/session") {
129129
if (url.searchParams.has("parentID")) {
@@ -447,7 +447,7 @@ test("vertical session tabs switch to horizontal below readable content width",
447447
state: state.path,
448448
config: {
449449
animations: false,
450-
tabs: { enabled: true, layout: "vertical", indicators: "status" },
450+
tabs: { mode: "on", layout: "vertical", indicators: "status" },
451451
session: { sidebar: "hide" },
452452
},
453453
args: { sessionID: session.id },
@@ -485,7 +485,7 @@ test("narrow vertical session tabs collapse to a compact rail with the terminal"
485485
state: state.path,
486486
config: {
487487
animations: false,
488-
tabs: { enabled: true, layout: "vertical", indicators: "status" },
488+
tabs: { mode: "on", layout: "vertical", indicators: "status" },
489489
session: { sidebar: "hide" },
490490
},
491491
args: { sessionID: session.id },
@@ -526,7 +526,7 @@ test("automatic rename refreshes the displayed title before settling, even witho
526526
width: 110,
527527
height: 20,
528528
state: state.path,
529-
config: { tabs: { enabled: true, layout: "vertical" }, session: { sidebar: "hide" } },
529+
config: { tabs: { mode: "on", layout: "vertical" }, session: { sidebar: "hide" } },
530530
args: { sessionID: session.id },
531531
fetch: async (url, request) => {
532532
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
585585
width,
586586
height: 55,
587587
state: state.path,
588-
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } },
588+
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } },
589589
args: { sessionID: session.id },
590590
fetch: (url) => {
591591
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,
690690
width: 100,
691691
height: 40,
692692
state: state.path,
693-
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide", tps: true } },
693+
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide", tps: true } },
694694
args: { sessionID: session.id },
695695
fetch: (url) => {
696696
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
886886
let session: unknown
887887
await using setup = await createAppFixture({
888888
state: state.path,
889-
config: { animations: false, tabs: { enabled: false }, keybinds: { "session.new": "f6" } },
889+
config: { animations: false, tabs: { mode: "off" }, keybinds: { "session.new": "f6" } },
890890
fetch: async (url, request) => {
891891
requests.push(url)
892892
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
10241024
let failedReads = 0
10251025
await using setup = await createAppFixture({
10261026
state: state.path,
1027-
config: { animations: false, tabs: { enabled: false }, session: { sidebar: "hide" } },
1027+
config: { animations: false, tabs: { mode: "off" }, session: { sidebar: "hide" } },
10281028
args: { sessionID: session.id },
10291029
fetch: (url) => {
10301030
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
@@ -1456,7 +1456,7 @@ test.each([100, 44])(
14561456
width,
14571457
state: state.path,
14581458
args: { sessionID: session.id },
1459-
config: { animations: false, tabs: { enabled: false } },
1459+
config: { animations: false, tabs: { mode: "off" } },
14601460
fetch: (url) => {
14611461
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
14621462
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
@@ -1620,7 +1620,7 @@ test.each([44, 100])(
16201620
width,
16211621
state: state.path,
16221622
args: { sessionID: session.id },
1623-
config: { animations: false, tabs: { enabled: false } },
1623+
config: { animations: false, tabs: { mode: "off" } },
16241624
fetch: (url) => {
16251625
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
16261626
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })

‎packages/tui/test/completion-notices.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ test.each([40, 120])("shell completion notices do not navigate at width %s", asy
8989
app: { name: "test", version: "test", channel: "test" },
9090
server: { endpoint: { url: server.url.toString() } },
9191
config: {
92-
get: async () => ({ animations: false, tabs: { enabled: false } }),
92+
get: async () => ({ animations: false, tabs: { mode: "off" } }),
9393
update: async () => ({}),
9494
},
9595
packages: { prepare: async () => ({ directory: "" }) },
@@ -170,7 +170,7 @@ test.each([40, 120])("subagent completion notices navigate to the child session
170170
app: { name: "test", version: "test", channel: "test" },
171171
server: { endpoint: { url: server.url.toString() } },
172172
config: {
173-
get: async () => ({ animations: false, tabs: { enabled: false } }),
173+
get: async () => ({ animations: false, tabs: { mode: "off" } }),
174174
update: async () => ({}),
175175
},
176176
packages: { prepare: async () => ({ directory: "" }) },

‎packages/tui/test/component/session-tabs-mouse.test.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ test("releasing a transcript selection over tab controls does not activate them"
2828
const app = await testRender(
2929
() => (
3030
<TestTuiContexts>
31-
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
31+
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
3232
<ThemeProvider mode="dark" source={emptyThemeSource}>
3333
<box flexDirection="column">
3434
<SessionTabs controller={controller} animations={false} />
@@ -79,7 +79,7 @@ test("middle-click closes a session tab without selecting it", async () => {
7979
const app = await testRender(
8080
() => (
8181
<TestTuiContexts>
82-
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
82+
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
8383
<ThemeProvider mode="dark" source={emptyThemeSource}>
8484
<SessionTabs controller={controller} animations={false} />
8585
</ThemeProvider>
@@ -127,7 +127,7 @@ test("keeps consecutive close controls fixed across overflow window changes", as
127127
const app = await testRender(
128128
() => (
129129
<TestTuiContexts>
130-
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
130+
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
131131
<ThemeProvider mode="dark" source={emptyThemeSource}>
132132
<SessionTabs controller={controller} animations={false} />
133133
</ThemeProvider>
@@ -178,7 +178,7 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
178178
const app = await testRender(
179179
() => (
180180
<TestTuiContexts>
181-
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
181+
<ConfigProvider config={createTuiResolvedConfig({ tabs: { mode: "on" } })}>
182182
<ThemeProvider mode="dark" source={emptyThemeSource}>
183183
<box flexDirection="column">
184184
<SessionTabs controller={controller} animations={false} />

‎packages/tui/test/component/session-tabs-status.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
3333
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
3434
const [active, setActive] = createSignal("second")
3535
const [newTab, setNewTab] = createSignal(false)
36-
const settings: Info = { tabs: { enabled: true } }
36+
const settings: Info = { tabs: { mode: "on" } }
3737
const copied: string[] = []
3838
let config!: ReturnType<typeof useConfig>
3939
let theme!: ReturnType<typeof useTheme>

‎packages/tui/test/config-v2.test.tsx‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@ test("validates mini replay and work spinner settings", () => {
3333
test("validates the session tabs setting", () => {
3434
const decode = Schema.decodeUnknownSync(Info)
3535

36-
expect(decode({ tabs: { enabled: true, layout: "vertical", indicators: "numbers" } })).toEqual({
37-
tabs: { enabled: true, layout: "vertical", indicators: "numbers" },
36+
expect(decode({ tabs: { mode: "on", layout: "vertical", indicators: "numbers" } })).toEqual({
37+
tabs: { mode: "on", layout: "vertical", indicators: "numbers" },
3838
})
3939
expect(() => decode({ tabs: { indicators: "unknown" } })).toThrow()
4040
expect(() => decode({ tabs: { layout: true } })).toThrow()
41-
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
41+
expect(() => decode({ tabs: { mode: true } })).toThrow()
42+
expect(decode({ tabs: { enabled: false } })).toEqual({ tabs: { enabled: false } })
4243
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
4344
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
4445
expect(decode({ session: { tps: false } })).toEqual({ session: { tps: false } })
@@ -55,21 +56,46 @@ test("resolves nested config and keybind defaults", () => {
5556
diffs: { view: "split" },
5657
debug: { devtools: true },
5758
},
58-
{ terminalSuspend: true },
59+
{ terminalSuspend: true, environment: {} },
5960
)
6061

6162
expect(config.leader.timeout).toBe(500)
6263
expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
6364
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
6465
expect(config.diffs).toEqual({ view: "split" })
6566
expect(config.debug).toEqual({ devtools: true })
66-
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal", indicators: "status" })
67+
expect(config.tabs).toEqual({
68+
mode: "auto",
69+
enabled: true,
70+
scope: "cwd",
71+
layout: "horizontal",
72+
indicators: "status",
73+
})
6774
expect(config.session.new_location).toBe("launch")
6875
expect(config.session.tps).toBe(true)
6976
})
7077

78+
test("resolves automatic tabs from the terminal environment", () => {
79+
expect(resolve({}, { terminalSuspend: true, environment: {} }).tabs.enabled).toBe(true)
80+
expect(resolve({}, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).tabs.enabled).toBe(false)
81+
expect(
82+
resolve({ tabs: { mode: "on" } }, { terminalSuspend: true, environment: { HERDR_ENV: "1" } }).tabs.enabled,
83+
).toBe(true)
84+
expect(resolve({ tabs: { mode: "off" } }, { terminalSuspend: true, environment: {} }).tabs.enabled).toBe(false)
85+
expect(resolve({ tabs: { enabled: false } }, { terminalSuspend: true, environment: {} }).tabs).toMatchObject({
86+
mode: "off",
87+
enabled: false,
88+
})
89+
expect(
90+
resolve({ tabs: { mode: "on", enabled: false } }, { terminalSuspend: true, environment: {} }).tabs,
91+
).toMatchObject({ mode: "on", enabled: true })
92+
})
93+
7194
test("shows resolved tab defaults in settings", () => {
72-
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
95+
expect(settings.find((setting) => setting.path.join(".") === "tabs.mode")).toMatchObject({
96+
default: "auto",
97+
values: ["off", "on", "auto"],
98+
})
7399
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
74100
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
75101
expect(settings.find((setting) => setting.path.join(".") === "tabs.indicators")).toMatchObject({

0 commit comments

Comments
 (0)