Compare commits

...

1 Commits

Author SHA1 Message Date
James Long 251db94dd0 feat(tui): add tab status icons experiment 2026-08-26 18:28:55 +00:00
4 changed files with 170 additions and 14 deletions
@@ -14,6 +14,11 @@ type Experiment = {
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = [
{
id: "tab-status-icons",
title: "Tab status icons",
description: "Show a spinner while working, a small dot when unread, and tab numbers otherwise",
},
{
id: "session-preview-tabs",
title: "Session preview tabs",
+40 -12
View File
@@ -39,6 +39,7 @@ import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/m
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { Spinner } from "./spinner"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -377,13 +378,13 @@ export function SessionTabs(
}
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean; width?: number }) {
const contextTabs = useSessionTabs()
const tabs: SessionTabsController = props.controller ?? contextTabs
const tabs: SessionTabsController = props.controller ?? useSessionTabs()
const data = useData()
const theme = useTheme("elevated")
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const statusIcons = () => config.experimental?.["tab-status-icons"] === true
const width = () => props.width ?? SESSION_SIDEBAR_WIDTH
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
@@ -755,14 +756,26 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingRight={1}>
<text
width={numberWidth() + 1}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
<Show
when={statusIcons() && runs()}
fallback={
<text
width={numberWidth() + 1}
fg={numberColor()}
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{(statusIcons() && !status().busy && status().unread !== undefined
? "•"
: sessionTabNumberLabel(index())
).padStart(numberWidth())}
</text>
}
>
{sessionTabNumberLabel(index()).padStart(numberWidth())}
</text>
<box width={numberWidth() + 1} flexShrink={0} paddingLeft={numberWidth() - 1}>
<Spinner color={theme.text.subdued} animations={animations()} />
</box>
</Show>
<text
width={titleWidth()}
fg={foreground()}
@@ -930,6 +943,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const statusIcons = () => config.experimental?.["tab-status-icons"] === true
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
@@ -1332,9 +1346,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onLevel={setSweepLevel}
/>
<box zIndex={1} width="100%" flexDirection="row">
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
{(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())}
</text>
<Show
when={statusIcons() && runs()}
fallback={
<text width={numberWidth() + 1} fg={numberColor()} selectable={false} attributes={bold()}>
{(tab === NEW_SESSION_TAB
? "+"
: statusIcons() && !status().busy && status().unread !== undefined
? "•"
: sessionTabNumberLabel(tabNumber() - 1)
).padStart(numberWidth())}
</text>
}
>
<box width={numberWidth() + 1} flexShrink={0} paddingLeft={numberWidth() - 1}>
<Spinner color={theme.text.subdued} animations={animations()} />
</box>
</Show>
<text
width={availableTitleWidth()}
fg={foreground()}
+2 -2
View File
@@ -10,13 +10,13 @@ export { SPINNER_FRAMES } from "./spinner-frames"
registerOpencodeSpinner()
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
export function Spinner(props: { children?: JSX.Element; color?: RGBA; animations?: boolean }) {
const theme = useTheme()
const config = useConfig().data
const color = () => props.color ?? theme.text.subdued
return (
<Show
when={config.animations ?? true}
when={props.animations ?? config.animations ?? true}
fallback={<text fg={color()}>{props.children ? <>⋯ {props.children}</> : "⋯"}</text>}
>
<box flexDirection="row" gap={1}>
@@ -0,0 +1,123 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider, useConfig } from "../../src/config"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { SPINNER_FRAMES } from "../../src/component/spinner"
import { ClientProvider } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { ThemeProvider } from "../../src/context/theme"
import { emptyThemeSource } from "../fixture/fixture"
import { createApi, createEventStream, createFetch } from "../fixture/tui-client"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
for (const orientation of ["horizontal", "vertical"] as const) {
for (const width of [48, 120]) {
for (const mode of ["light", "dark"] as const) {
test(`${orientation} tab status icons toggle and update at ${width} columns in ${mode} mode`, async () => {
const [finished, setFinished] = createSignal(false)
const [read, setRead] = createSignal(false)
const [blocked, setBlocked] = createSignal(true)
const controller = {
tabs: () => [
{ sessionID: "idle", title: "Idle" },
{ sessionID: "busy", title: "Busy" },
{ sessionID: "done", title: "Done" },
{ sessionID: "wait", title: "Wait" },
],
current: () => "idle",
select() {},
close() {},
move() {},
detail: () => "project",
status(sessionID) {
return {
...EMPTY_SESSION_TAB_STATUS,
busy: sessionID === "wait" || (sessionID === "busy" && !finished()),
attention: sessionID === "wait" && blocked(),
unread: !read() && (sessionID === "done" || sessionID === "busy") ? "activity" : undefined,
}
},
} satisfies SessionTabsController
let config!: ReturnType<typeof useConfig>
let configuration = { animations: false, experimental: { "tab-status-icons": false } }
function Tabs() {
config = useConfig()
return <SessionTabs controller={controller} orientation={orientation} width={Math.min(width, 32)} />
}
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider
config={createTuiResolvedConfig(configuration)}
service={{
get: async () => configuration,
update: async (update) => {
configuration = structuredClone(configuration)
update(configuration)
return configuration
},
}}
>
<ClientProvider api={createApi(createFetch(undefined, createEventStream()).fetch)}>
<DataProvider>
<ThemeProvider mode={mode} source={emptyThemeSource}>
<Tabs />
</ThemeProvider>
</DataProvider>
</ClientProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width, height: 16 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("4 Wait"))
expect(app.captureCharFrame()).toContain("1 Idle")
expect(app.captureCharFrame()).toContain("2 Busy")
expect(app.captureCharFrame()).toContain("3 Done")
await config.update((draft) => {
draft.experimental["tab-status-icons"] = true
})
await app.waitForFrame((frame) => frame.includes("⋯ Busy"))
expect(app.captureCharFrame()).toContain("• Done")
expect(app.captureCharFrame()).toContain("1 Idle")
expect(app.captureCharFrame()).toContain("4 Wait")
expect(app.captureCharFrame()).not.toMatch(/[●⚠]/)
await config.update((draft) => {
draft.animations = true
})
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} Busy`)))
expect(app.captureCharFrame()).toContain("4 Wait")
setBlocked(false)
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} Wait`)))
setFinished(true)
await app.waitForFrame((frame) => frame.includes("• Busy"))
setRead(true)
await app.waitForFrame((frame) => frame.includes("3 Done"))
expect(app.captureCharFrame()).toContain("2 Busy")
expect(app.captureCharFrame()).not.toMatch(/[•●⚠]/)
setRead(false)
await app.waitForFrame((frame) => frame.includes("• Done"))
await config.update((draft) => {
draft.experimental["tab-status-icons"] = false
})
await app.waitForFrame((frame) => frame.includes("4 Wait"))
expect(app.captureCharFrame()).toContain("2 Busy")
expect(app.captureCharFrame()).toContain("3 Done")
expect(app.captureCharFrame()).not.toContain("•")
} finally {
app.renderer.destroy()
}
})
}
}
}