From 251db94dd0be0b69b63dd9c551170e94f0b9d3a5 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 26 Aug 2026 18:28:55 +0000 Subject: [PATCH] feat(tui): add tab status icons experiment --- .../tui/src/component/dialog-experiments.tsx | 5 + packages/tui/src/component/session-tabs.tsx | 52 ++++++-- packages/tui/src/component/spinner.tsx | 4 +- .../component/session-tabs-status.test.tsx | 123 ++++++++++++++++++ 4 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 packages/tui/test/component/session-tabs-status.test.tsx diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 84334603348b..9aa10c2d7ddd 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -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", diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 7ebb70614e09..301c85ecb898 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -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} /> - + {(statusIcons() && !status().busy && status().unread !== undefined + ? "•" + : sessionTabNumberLabel(index()) + ).padStart(numberWidth())} + + } > - {sessionTabNumberLabel(index()).padStart(numberWidth())} - + + + + 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} /> - - {(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())} - + + {(tab === NEW_SESSION_TAB + ? "+" + : statusIcons() && !status().busy && status().unread !== undefined + ? "•" + : sessionTabNumberLabel(tabNumber() - 1) + ).padStart(numberWidth())} + + } + > + + + + props.color ?? theme.text.subdued return ( {props.children ? <>⋯ {props.children} : "⋯"}} > diff --git a/packages/tui/test/component/session-tabs-status.test.tsx b/packages/tui/test/component/session-tabs-status.test.tsx new file mode 100644 index 000000000000..ce7e740dc2a2 --- /dev/null +++ b/packages/tui/test/component/session-tabs-status.test.tsx @@ -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 + let configuration = { animations: false, experimental: { "tab-status-icons": false } } + function Tabs() { + config = useConfig() + return + } + const app = await testRender( + () => ( + + configuration, + update: async (update) => { + configuration = structuredClone(configuration) + update(configuration) + return configuration + }, + }} + > + + + + + + + + + + ), + { 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() + } + }) + } + } +}