refactor(tui): simplify graduated preview tabs

This commit is contained in:
Kit Langton 2026-08-31 19:47:32 -04:00
parent 76b4f07ad5
commit 23d9ca65f9
3 changed files with 6 additions and 160 deletions

View file

@ -1,84 +1,19 @@
import { createMemo, createSignal } from "solid-js"
import { useConfig } from "../config"
import { DialogSelect } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useToast } from "../ui/toast"
type Experiment = {
id: string
title: string
description: string
}
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = []
export function DialogExperiments() {
const config = useConfig()
const theme = useTheme()
const toast = useToast()
const [selected, setSelected] = createSignal<Experiment>()
const [saving, setSaving] = createSignal(false)
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
const options = createMemo(() =>
experiments.map((experiment) => ({
title: experiment.title,
searchText: experiment.description,
footer: enabled(experiment) ? "on" : "off",
value: experiment,
})),
)
// All experiments are booleans, so either direction toggles.
async function change(experiment = selected()) {
if (saving()) return
if (!experiment) return
const next = !enabled(experiment)
setSaving(true)
await config
.update((draft) => {
if (!draft.experimental || typeof draft.experimental !== "object") draft.experimental = {}
draft.experimental[experiment.id] = next
})
.catch(toast.error)
.finally(() => setSaving(false))
}
return (
<DialogSelect
title="Experiments"
options={options()}
renderFilter={experiments.length > 0}
onMove={(option) => setSelected(option.value)}
onSelect={(option) => void change(option.value)}
options={[]}
renderFilter={false}
emptyView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No experiments available</text>
</box>
}
footerHints={experiments.length > 0 ? [{ title: "←/→", label: "change" }] : []}
bindings={
experiments.length > 0
? [
{
bind: "left",
title: "Previous value",
group: "Experiments",
run: () => void change(),
},
{
bind: "right",
title: "Next value",
group: "Experiments",
run: () => void change(),
},
]
: []
}
/>
)
}

View file

@ -1,4 +1,5 @@
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { isDeepEqual } from "remeda"
import { createSimpleContext } from "./helper"
@ -73,9 +74,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
},
key: "sessionID",
})
const [preview, updatePreview] = storage.memory<{ global?: string; cwd?: string }>("session-tab-preview", {
initial: {},
})
const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
@ -107,16 +106,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const previewID = () => preview[config.tabs.scope]
const setPreview = (sessionID: string | undefined) => {
const scope = config.tabs.scope
updatePreview((draft) => {
if (sessionID === undefined) {
delete draft[scope]
return
}
draft[scope] = sessionID
})
}
const setPreview = (sessionID: string | undefined) => updatePreview(config.tabs.scope, sessionID)
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs.scope
@ -179,11 +169,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
createEffect(() => {
if (enabled()) return
promotedSession = undefined
if (!preview.global && !preview.cwd) return
updatePreview((draft) => {
delete draft.global
delete draft.cwd
})
updatePreview({ global: undefined, cwd: undefined })
})
// Shared storage updates must not re-admit a tab unless this client changes route or scope.

View file

@ -5,10 +5,7 @@ import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { Keymap } from "../../src/context/keymap"
import { ThemeProvider } from "../../src/context/theme"
import { DialogProvider } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
@ -65,78 +62,6 @@ test("releasing a transcript selection over tab controls does not activate them"
}
})
test("the horizontal tab context menu keeps preview tabs open without selecting them", async () => {
const [active, setActive] = createSignal("first")
const promoted: string[] = []
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
],
current: active,
select: setActive,
close() {},
move() {},
isPreview: (sessionID: string) => sessionID === "second",
promote: (sessionID: string) => promoted.push(sessionID),
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<SessionTabs controller={controller} animations={false} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 60, height: 8 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
const first = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("First"))
await app.mockMouse.click(app.captureCharFrame().split("\n")[first]!.indexOf("First"), first, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame()).toContain("Close")
expect(app.captureCharFrame()).not.toContain("Keep open")
app.mockInput.pressKey("c", { ctrl: true })
await app.waitForFrame((frame) => !frame.includes("Rename"))
const second = app
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Second"))
await app.mockMouse.click(app.captureCharFrame().split("\n")[second]!.indexOf("Second"), second, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Keep open"))
expect(app.captureCharFrame()).toContain("Rename")
expect(app.captureCharFrame()).toContain("Close")
expect(active()).toBe("first")
const frame = app.captureCharFrame().split("\n")
const row = frame.findIndex((line) => line.includes("Keep open"))
await app.mockMouse.click(frame[row]!.indexOf("Keep open"), row)
await app.waitForFrame((frame) => !frame.includes("Rename"))
expect(promoted).toEqual(["second"])
expect(active()).toBe("first")
} finally {
app.renderer.destroy()
}
})
test("double-clicking a preview tab keeps it open without promoting permanent tabs", async () => {
const [active, setActive] = createSignal("first")
const promoted: string[] = []