feat(tui): make session tab status explicit (#45347)

This commit is contained in:
Kit Langton 2026-08-26 20:12:15 -04:00 committed by GitHub
parent b5cb5e2a55
commit 4ac19d744a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 979 additions and 131 deletions

View file

@ -94,7 +94,7 @@ async function open(from?: string): Promise<Session> {
screenMode: "split-footer",
footerHeight: 4,
targetFps: 60,
useKittyKeyboard: {},
useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true },
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},

View file

@ -21,6 +21,7 @@
},
"scripts": {
"build": "bun run script/build.ts",
"test": "bun test --timeout 30000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {

View file

@ -121,6 +121,12 @@ export const DEFAULT_THEME = {
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.700",
},
status: {
running: "$hue.interactive.800",
question: "$text.feedback.info.default",
permission: "$text.feedback.warning.default",
unread: "$hue.accent.800",
},
feedback: {
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
@ -336,6 +342,12 @@ export const DEFAULT_THEME = {
$disabled: "$hue.neutral.500",
$selected: "$hue.interactive.500",
},
status: {
running: "$hue.interactive.200",
question: "$text.feedback.info.default",
permission: "$text.feedback.warning.default",
unread: "$hue.accent.200",
},
feedback: {
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },

View file

@ -1,7 +1,8 @@
import type { ThemeTokensDefinition } from "./index.js"
import type { Mode, ThemeTokensDefinition } from "./index.js"
import { DEFAULT_THEME } from "./defaults.js"
import { ActionVariant, FeedbackKind } from "./schema.js"
export function fallback(): ThemeTokensDefinition {
export function fallback(mode: Mode): ThemeTokensDefinition {
const red = "#ff0000"
return {
@ -9,6 +10,7 @@ export function fallback(): ThemeTokensDefinition {
default: red,
action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])),
formfield: { default: red },
status: DEFAULT_THEME[mode].text.status,
feedback: Object.fromEntries(FeedbackKind.literals.map((kind) => [kind, { default: red }])),
},
background: {

View file

@ -46,7 +46,7 @@ export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "
const selected = selectThemeMode(document, mode)
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
const core = expandTokens(fallback())
const core = expandTokens(fallback(selected.mode))
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
return resolveExpandedTheme({

View file

@ -108,6 +108,14 @@ const TextDefinition = Schema.Struct({
subdued: Schema.optional(ColorValue),
action: Schema.optional(ActionColorDefinition),
formfield: Schema.optional(StatefulColorDefinition),
status: Schema.optional(
Schema.Struct({
running: Schema.optional(ColorValue),
question: Schema.optional(ColorValue),
permission: Schema.optional(ColorValue),
unread: Schema.optional(ColorValue),
}),
),
feedback: Schema.optional(
Schema.Struct({
error: Schema.optional(TextFeedbackDefinition),

View file

@ -30,6 +30,12 @@ export type ResolvedThemeTokens = {
readonly subdued: RGBA
readonly action: Readonly<Record<ActionVariant, StatefulColor>>
readonly formfield: FormfieldColor
readonly status: {
readonly running: RGBA
readonly question: RGBA
readonly permission: RGBA
readonly unread: RGBA
}
readonly feedback: Readonly<Record<FeedbackKind, { readonly default: RGBA; readonly subdued: RGBA }>>
}
readonly background: {

View file

@ -0,0 +1,42 @@
import { expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { Schema } from "effect"
import { DEFAULT_THEME, ThemeDocument, migrateV1, resolveThemeDocument } from "../src/tui/index.js"
import type { ThemeV1Json } from "../src/tui/v1.js"
test.each(["light", "dark"] as const)("built-in %s themes resolve status colors", async (mode) => {
const source: ThemeV1Json = await Bun.file(
new URL("../../tui/src/theme/assets/opencode.json", import.meta.url),
).json()
for (const document of [DEFAULT_THEME, migrateV1(source)]) {
const theme = resolveThemeDocument(document, mode)
expect(theme.text.status.running.equals(theme.hue.interactive[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.text.status.question.equals(theme.text.feedback.info.default)).toBeTrue()
expect(theme.text.status.permission.equals(theme.text.feedback.warning.default)).toBeTrue()
expect(theme.text.status.unread.equals(theme.hue.accent[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.contextual.elevated.text.status).toEqual(theme.text.status)
}
})
test.each(["light", "dark"] as const)("custom %s themes inherit and override status colors", (mode) => {
for (const standalone of [false, true]) {
const theme = resolveThemeDocument(
Schema.decodeUnknownSync(ThemeDocument)({
version: 2,
standalone,
[mode]: {
hue: { ...DEFAULT_THEME[mode].hue, interactive: "$hue.purple", accent: "$hue.orange" },
text: {
feedback: { warning: { default: "#654321" } },
status: { question: "#123456" },
},
},
}),
mode,
)
expect(theme.text.status.running.equals(theme.hue.purple[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.text.status.unread.equals(theme.hue.orange[mode === "light" ? 800 : 200])).toBeTrue()
expect(theme.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue()
expect(theme.text.status.permission.equals(RGBA.fromHex("#654321"))).toBeTrue()
}
})

View file

@ -230,7 +230,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
targetFps: 60,
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true },
autoFocus: false,
openConsoleOnError: false,
useMouse: config.mouse,

View file

@ -144,6 +144,15 @@ export const settings: Setting[] = [
values: ["horizontal", "vertical"],
keywords: ["sidebar", "orientation", "left"],
},
{
title: "Indicators",
category: "Tabs",
path: ["tabs", "indicators"],
default: "status",
values: ["status", "numbers"],
labels: ["status icons", "always show numbers"],
keywords: ["tab numbers", "number mode", "status icons"],
},
{
title: "Layout",
category: "Diffs",

View file

@ -16,7 +16,7 @@ import { Portal, useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { useSessionTabs } from "../context/session-tabs"
import { useData } from "../context/data"
import { useTheme, useThemes } from "../context/theme"
import { useTheme } from "../context/theme"
import {
adaptiveSessionTabLayout,
moveSessionTab,
@ -39,6 +39,26 @@ import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/m
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { registerOpencodeSpinner } from "./register-spinner"
import { SPINNER_FRAMES } from "./spinner-frames"
registerOpencodeSpinner()
export const TAB_SPINNERS = {
dots: { frames: SPINNER_FRAMES, interval: 80 },
arcs: { frames: ["◜", "◝", "◞", "◟"], interval: 120 },
quadrants: { frames: ["◴", "◷", "◶", "◵"], interval: 120 },
line: { frames: ["|", "/", "-", "\\"], interval: 120 },
}
export type TabSpinner = keyof typeof TAB_SPINNERS
export const TAB_UNREAD_MARKERS = {
"small-dot": "•",
dot: "●",
square: "▪",
"large-square": "■",
}
export type TabUnreadMarker = keyof typeof TAB_UNREAD_MARKERS
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@ -89,6 +109,75 @@ const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_T
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, level = 1) =>
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
function tabFeedbackColor(status: SessionTabsStatus, theme: ReturnType<typeof useTheme>) {
if (status.attention) return theme.text.status[status.attention]
if (status.unread === "error") return theme.text.feedback.error.default
return undefined
}
function TabIndicator(props: {
status: SessionTabsStatus
label: string
width: number
color: RGBA
unreadColor: RGBA
backgroundColor: RGBA
flashColor: RGBA
animations: boolean
numbers: boolean
spinner?: TabSpinner
unreadMarker?: TabUnreadMarker
attributes?: number
}) {
const runs = () => props.status.busy && !props.status.attention
const pending = createMemo(() => props.status.busy || Boolean(props.status.attention))
const unread = createMemo(() => Boolean(props.status.unread) && !pending())
const unreadColor = createMemo<RGBA>((previous) => (unread() ? props.unreadColor : previous), props.unreadColor)
const fade = createAnimatable(
{ opacity: unread() ? 1 : 0 },
{ enabled: () => props.animations, transition: tween({ duration: 0.18 }) },
)
createComputed(() => {
if (unread()) return fade.jump({ opacity: 1 })
if (pending()) return fade.jump({ opacity: 0 })
fade.animate({ opacity: 0 })
})
const fading = () => !props.status.unread && fade.value().opacity > 0
const color = () => {
if (props.numbers) return props.color
if (unread()) return props.unreadColor
if (!fading()) return props.color
const opacity = fade.value().opacity
// Brighten during the first fifth, then dissolve into the tab background.
const flash = Math.max(0, 1 - Math.abs(opacity - 0.8) / 0.2)
return tint(props.backgroundColor, tint(unreadColor(), props.flashColor, flash * 0.3), Math.min(1, opacity / 0.8))
}
const spinner = () => TAB_SPINNERS[props.spinner ?? "dots"]
const label = () => {
if (props.numbers) return props.label
if (props.status.attention === "permission") return "!"
if (props.status.attention === "question") return "?"
if (runs()) return spinner().frames[0]
if (props.label === "+") return "+"
if (props.status.unread || fading()) return TAB_UNREAD_MARKERS[props.unreadMarker ?? "small-dot"]
return ""
}
return (
<box width={props.width + 1} flexShrink={0} flexDirection="row" justifyContent="flex-end" paddingRight={1}>
<Show
when={runs() && props.animations && !props.numbers}
fallback={
<text fg={color()} selectable={false} attributes={props.attributes}>
{label()}
</text>
}
>
<spinner frames={spinner().frames} interval={spinner().interval} color={props.color} />
</Show>
</box>
)
}
function createPreviewDoubleClick(tabs: SessionTabsController) {
let previous: { sessionID: string; time: number } | undefined
return (sessionID: string) => {
@ -106,6 +195,15 @@ function createPreviewDoubleClick(tabs: SessionTabsController) {
}
}
function createGlowLevel(dimmed: () => boolean, animations: () => boolean) {
const motion = createAnimatable(
{ level: dimmed() ? 0.7 : 1 },
{ enabled: animations, transition: tween({ duration: 0.2 }) },
)
createEffect(() => motion.animate({ level: dimmed() ? 0.7 : 1 }))
return () => motion.value().level
}
function createNumberIgnition(runs: () => boolean, prompt: () => number, animations: () => boolean) {
const ignition = createAnimatable({ level: 0 }, { enabled: animations, transition: tween({ duration: 0.7 }) })
let wasRunning = runs()
@ -360,35 +458,68 @@ export function SessionTabs(
props: {
controller?: SessionTabsController
animations?: boolean
spinner?: TabSpinner
unreadMarker?: TabUnreadMarker
orientation?: "horizontal" | "vertical"
width?: number
} = {},
) {
const keymap = Keymap.use()
const config = useConfig().data
const [numbers, setNumbers] = createSignal(config.tabs.indicators === "numbers")
createEffect(() => {
if (config.tabs.indicators === "numbers" || !keymap.control()) {
setNumbers(config.tabs.indicators === "numbers")
return
}
// Brief Control chords should not flash the tab numbers.
const timeout = setTimeout(() => setNumbers(true), 300)
onCleanup(() => clearTimeout(timeout))
})
return (
<Switch>
<Match when={props.orientation === "vertical"}>
<VerticalSessionTabs controller={props.controller} animations={props.animations} width={props.width} />
<VerticalSessionTabs
controller={props.controller}
animations={props.animations}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
numbers={numbers()}
width={props.width}
/>
</Match>
<Match when={true}>
<HorizontalSessionTabs controller={props.controller} animations={props.animations} />
<HorizontalSessionTabs
controller={props.controller}
animations={props.animations}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
numbers={numbers()}
/>
</Match>
</Switch>
)
}
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean; width?: number }) {
function VerticalSessionTabs(props: {
controller?: SessionTabsController
animations?: boolean
numbers: boolean
spinner?: TabSpinner
unreadMarker?: TabUnreadMarker
width?: number
}) {
const contextTabs = useSessionTabs()
const tabs: SessionTabsController = props.controller ?? contextTabs
const data = useData()
const theme = useTheme("elevated")
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const width = () => props.width ?? SESSION_SIDEBAR_WIDTH
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [addHovered, setAddHovered] = createSignal(false)
@ -422,8 +553,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
...status,
complete: sessionTabComplete(status.unread, status.busy),
runs: status.busy && !status.attention,
glows:
tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
glows: Boolean(
status.attention || (tab.sessionID !== activeID() && !status.busy && status.unread !== undefined),
),
},
] as const
}),
@ -564,7 +696,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), tint(theme.text.default, pulseBackground(), 0.25), Number(selected()))
const color = tint(base, glowHue(), numberGlow.value().level)
const color = tabFeedbackColor(status(), theme) ?? tint(base, glowHue(), numberGlow.value().level)
const runningColor = runs() ? activeNumber() : color
return sweepLevel() === 0
? tint(runningColor, theme.text.default, numberIgnition.value().level)
@ -575,20 +707,21 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return selected() ? theme.text.default : theme.text.subdued
}
const complete = () => status().complete
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to accent.
// Latched so a resolving glow fades out in the hue it lit with instead of snapping to the unread color.
let lastGlowHue: RGBA | undefined
const glowHue = () => {
if (status().attention) return (lastGlowHue = theme.text.feedback.warning.default)
if (status().unread === "error") return (lastGlowHue = theme.text.feedback.error.default)
if (status().unread !== undefined) return (lastGlowHue = accent())
return lastGlowHue ?? accent()
const feedback = tabFeedbackColor(status(), theme)
if (feedback) return (lastGlowHue = feedback)
if (status().unread !== undefined) return (lastGlowHue = unreadColor())
return lastGlowHue ?? unreadColor()
}
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
const flashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.7))
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
const glowLevel = createGlowLevel(() => selected() && Boolean(status().attention), animations)
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45 * glowLevel()))
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25 * glowLevel()))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailTextColor = (index: number) =>
detailFades()
@ -619,17 +752,24 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
})
const previousGlows = () => previousStatus().glows
const previousRuns = () => previousStatus().runs
const previousGlowLevel = createGlowLevel(
() => previous()?.sessionID === activeID() && Boolean(previousStatus().attention),
animations,
)
const indicatorWidth = 10
let lastPreviousGlowHue: RGBA | undefined
const previousGlowHue = () => {
if (previousStatus().attention) return (lastPreviousGlowHue = theme.text.feedback.warning.default)
if (previousStatus().unread === "error")
return (lastPreviousGlowHue = theme.text.feedback.error.default)
if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = accent())
return lastPreviousGlowHue ?? accent()
const feedback = tabFeedbackColor(previousStatus(), theme)
if (feedback) return (lastPreviousGlowHue = feedback)
if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = unreadColor())
return lastPreviousGlowHue ?? unreadColor()
}
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
const separatorUpperColor = createMemo(() =>
tint(theme.background.default, previousGlowHue(), 0.1 * previousGlowLevel()),
)
const separatorLowerColor = createMemo(() =>
tint(theme.background.default, glowHue(), 0.12 * glowLevel()),
)
const titleColor = (index: number, separator: boolean) => {
const level = titleGlow.value().level
const color =
@ -729,11 +869,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
outerColor={tint(theme.background.default, theme.text.default, 0.006)}
flashColor={tint(theme.background.default, theme.text.default, 0.18)}
flashTail={8}
glowColor={tint(theme.background.default, glowHue(), 0.1)}
glowColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerGlowColor={theme.background.default}
glowTail={8}
outerGlowTail={5}
completionColor={tint(theme.background.default, glowHue(), 0.1)}
completionColor={tint(theme.background.default, glowHue(), 0.1 * glowLevel())}
outerCompletionColor={theme.background.default}
backgroundColor={theme.background.default}
/>
@ -755,14 +895,20 @@ 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}
<TabIndicator
status={status()}
label={sessionTabNumberLabel(index())}
width={numberWidth()}
color={numberColor()}
unreadColor={tabFeedbackColor(status(), theme) ?? unreadColor()}
backgroundColor={pulseBackground()}
flashColor={theme.text.default}
animations={animations()}
numbers={props.numbers}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{sessionTabNumberLabel(index()).padStart(numberWidth())}
</text>
/>
<text
width={titleWidth()}
fg={foreground()}
@ -923,11 +1069,16 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
)
}
function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
function HorizontalSessionTabs(props: {
controller?: SessionTabsController
animations?: boolean
spinner?: TabSpinner
unreadMarker?: TabUnreadMarker
numbers: boolean
}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [addHovered, setAddHovered] = createSignal(false)
@ -960,10 +1111,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onCleanup(clearCloseHold)
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const unreadColor = () => theme.text.status.unread
const activeNumber = () => theme.text.status.running
const idleNumber = () => tint(theme.text.formfield.default, theme.background.default, 0.55)
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const ordered = createMemo(() => {
@ -1211,13 +1361,11 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
// so it reads as a lift of the pulse color rather than a different hue.
const flashColor = () => tint(background(), theme.text.default, 0.65)
const feedbackColor = () => {
if (status().attention) return theme.text.feedback.warning.default
if (status().unread === "error") return theme.text.feedback.error.default
return undefined
}
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const feedbackColor = () => tabFeedbackColor(status(), theme)
const glowLevel = createGlowLevel(() => selected() && Boolean(status().attention), animations)
const glowColor = createMemo(() => tint(background(), feedbackColor() ?? unreadColor(), glowLevel()))
const glows = () =>
Boolean(status().attention || (!selected() && !status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
const numberWidth = () => Math.max(2, String(items().length).length)
@ -1273,7 +1421,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
hovered() === tab.sessionID && !selected()
? foreground()
: tint(idleNumber(), tint(theme.text.default, background(), 0.25), selection())
const color = feedback ?? (runs() ? activeNumber() : tint(base, accent(), activity()))
const color = runs() ? activeNumber() : (feedback ?? tint(base, unreadColor(), activity()))
// The number brightens faintly as the running sweep passes beneath it.
return tint(color, theme.text.default, Math.max(numberIgnition.value().level, 0.15 * sweepLevel()))
}
@ -1327,14 +1475,25 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
color={pulseColor()}
glowColor={glowColor()}
flashColor={flashColor()}
completionColor={accent()}
completionColor={unreadColor()}
backgroundColor={background()}
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>
<TabIndicator
status={status()}
label={tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)}
width={numberWidth()}
color={numberColor()}
unreadColor={feedbackColor() ?? unreadColor()}
backgroundColor={background()}
flashColor={theme.text.default}
animations={animations()}
numbers={props.numbers}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
attributes={bold()}
/>
<text
width={availableTitleWidth()}
fg={foreground()}

View file

@ -159,6 +159,9 @@ export const Info = Schema.Struct({
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
description: "Show tabs in a horizontal strip or vertical sidebar",
}),
indicators: Schema.optional(Schema.Literals(["status", "numbers"])).annotate({
description: "Show status icons with numbers on held Control, or always show tab numbers",
}),
}),
).annotate({ description: "Tab strip settings" }),
mini: Schema.optional(
@ -231,6 +234,7 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
enabled: boolean
scope: "global" | "cwd"
layout: "horizontal" | "vertical"
indicators: "status" | "numbers"
}
}
@ -283,6 +287,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
enabled: input.tabs?.enabled ?? true,
scope: input.tabs?.scope ?? "cwd",
layout: input.tabs?.layout ?? "horizontal",
indicators: input.tabs?.indicators ?? "status",
},
}
}

View file

@ -13,7 +13,7 @@ import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { useRenderer } from "@opentui/solid"
import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
import { useConfig } from "../config"
import { TuiKeybind } from "../config/keybind"
@ -46,6 +46,7 @@ const Context = createContext<{
readonly keymap: OpenTuiKeymap
readonly config: KeymapConfig
readonly mode: Mode
readonly control: Accessor<boolean>
readonly dispatch: (id: string, input?: string) => void
readonly input: (id: string) => string | undefined
}>()
@ -55,6 +56,23 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
const config: KeymapConfig = props.config ?? useConfig().data
const keymap = createDefaultOpenTuiKeymap(renderer)
const mode = createMode(keymap)
const [control, setControl] = createSignal(false)
const modifier = (event: KeyEvent) => {
if (!/^(?:(left|right)(ctrl|shift|alt|super|hyper|meta)|iso_level[35]_shift)$/.test(event.name)) return
if (event.name === "leftctrl" || event.name === "rightctrl") setControl(event.eventType !== "release")
// Modifier-only reports are state changes, not input that cancels a selection or pending chord.
event.preventDefault()
event.stopPropagation()
}
const blur = () => setControl(false)
renderer.keyInput.prependListener("keypress", modifier)
renderer.keyInput.prependListener("keyrelease", modifier)
renderer.on("blur", blur)
onCleanup(() => {
renderer.keyInput.off("keypress", modifier)
renderer.keyInput.off("keyrelease", modifier)
renderer.off("blur", blur)
})
let invocation: { readonly id: string; readonly input?: string } | undefined
const dispatch = (id: string, input?: string) => {
const previous = invocation
@ -145,6 +163,7 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
keymap,
config,
mode,
control,
dispatch,
input: (id) => (invocation?.id === id ? invocation.input : undefined),
}}
@ -158,6 +177,8 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) {
export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/tui/context"
export interface Keymap {
/** Whether Control is held, when the terminal reports modifier events. */
readonly control: Accessor<boolean>
/** Dispatches a reachable command by ID. */
dispatch(id: string, input?: string): void
/** Controls mutually exclusive OpenCode input modes. */
@ -182,6 +203,7 @@ function use(): Keymap {
value.dispatch(id, input)
},
mode: value.mode,
control: value.control,
intercept: value.keymap.intercept.bind(value.keymap),
isLeader,
}

View file

@ -167,9 +167,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
? ("error" as const)
: ("activity" as const),
promptPulse: promptPulses()[session] ?? 0,
attention: members.some(
(id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
),
attention: members.some((id) => (data.session.permission.list(id)?.length ?? 0) > 0)
? ("permission" as const)
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
? ("question" as const)
: (false as const),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
}
}

View file

@ -26,26 +26,23 @@ export function StoryFooter(props: {
</box>
<Show when={props.status || props.message}>
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
<text fg={theme.text.default}>{props.status ?? ""}</text>
<Show when={props.status && props.message}>
<text fg={theme.text.subdued}> · </text>
</Show>
<text fg={theme.text.subdued}>{props.message ?? ""}</text>
<text fg={theme.text.default} wrapMode="none">
{props.status ?? ""}
<span style={{ fg: theme.text.subdued }}>
{props.status && props.message ? " · " : ""}
{props.message ?? ""}
</span>
</text>
</box>
</Show>
<box height={1} paddingLeft={1} paddingRight={1} flexDirection="row">
<text fg={theme.text.default} wrapMode="none">
<For each={props.controls}>
{(control, index) => (
<>
<Show when={index() > 0}>
<span> </span>
</Show>
{control.shortcut} <span style={{ fg: theme.text.subdued }}>{control.label}</span>
</>
)}
</For>
</text>
<box paddingLeft={1} paddingRight={1} flexDirection="row" flexWrap="wrap" columnGap={1}>
<For each={props.controls}>
{(control) => (
<text fg={theme.text.default} wrapMode="none" flexShrink={0}>
{control.shortcut} <span style={{ fg: theme.text.subdued }}>{control.label}</span>
</text>
)}
</For>
</box>
{/* The app-wide feature footer overlays the terminal's final row. */}
<box height={1} />

View file

@ -2,7 +2,15 @@ import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal, For } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
import {
EMPTY_SESSION_TAB_STATUS,
SessionTabs,
TAB_SPINNERS,
TAB_UNREAD_MARKERS,
type SessionTabsController,
type TabSpinner,
type TabUnreadMarker,
} from "../../../component/session-tabs"
import { closeSessionTab, cycleSessionTab, moveSessionTab } from "../../../context/session-tabs-model"
import { StoryFooter } from "./footer"
import type { Story } from "./index"
@ -24,6 +32,15 @@ const FIXTURE_TABS = [
{ sessionID: "fixture-12", title: "Prepare review", project: "opencode-drive" },
]
const FIXTURE_STATUSES: Record<string, FixtureStatus> = {
"fixture-2": { ...EMPTY_SESSION_TAB_STATUS, busy: true },
"fixture-3": { ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" },
"fixture-4": { ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "permission" },
"fixture-5": { ...EMPTY_SESSION_TAB_STATUS, unread: "activity" },
"fixture-6": { ...EMPTY_SESSION_TAB_STATUS, unread: "error" },
}
const FIXTURE_OUTCOMES = { "fixture-5": "completed", "fixture-6": "failed" } as const
// Plausible targets for the fake transcript's tool calls, picked per fixture index.
const TRANSCRIPT_FILES = [
"packages/tui/src/component/session-tabs.tsx",
@ -45,23 +62,26 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const setItems = (next: { sessionID: string; title?: string }[]) =>
setTabStore("items", reconcile(next, { key: "sessionID" }))
const [active, setActive] = createSignal<string | undefined>("fixture-1")
const [lastEvent, setLastEvent] = createSignal("press space to start a random tab")
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({})
const [lastEvent, setLastEvent] = createSignal("idle / working / question / permission / complete / error")
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>(FIXTURE_STATUSES)
const [orientation, setOrientation] = createSignal<"horizontal" | "vertical">("vertical")
const spinners = Object.keys(TAB_SPINNERS) as TabSpinner[]
const [spinner, setSpinner] = createSignal<TabSpinner>("dots")
const markers = Object.keys(TAB_UNREAD_MARKERS) as TabUnreadMarker[]
const [marker, setMarker] = createSignal<TabUnreadMarker>("small-dot")
const [animations, setAnimations] = createSignal(true)
// Unread clears on select, so the transcript remembers how each session's last run ended.
const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>({})
const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>(FIXTURE_OUTCOMES)
const number = (sessionID: string) => tabs().findIndex((tab) => tab.sessionID === sessionID) + 1
function finishRun(sessionID: string) {
function finishRun(sessionID: string, failed = Math.random() >= 0.75) {
if (!tabs().some((item) => item.sessionID === sessionID)) return
const roll = Math.random()
const failed = roll >= 0.75
const unread = active() === sessionID ? undefined : failed ? ("error" as const) : ("activity" as const)
batch(() => {
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, unread },
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, attention: false, unread },
}))
// An untitled session earns its title after its first completed run, like a real summarization.
const index = number(sessionID) - 1
@ -77,8 +97,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const status = statuses()[sessionID]
batch(() => {
setActive(sessionID)
if (status && (status.unread || status.attention))
setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined, attention: false } }))
if (status?.unread) setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined } }))
})
}
@ -134,7 +153,12 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const startRun = (sessionID: string) => {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: true, unread: undefined },
[sessionID]: {
...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS),
busy: true,
attention: false,
unread: undefined,
},
}))
setOutcomes((current) => {
const next = { ...current }
@ -170,7 +194,10 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
return pool[Math.floor(Math.random() * pool.length)]
}
const randomRunningTab = () => {
const candidates = tabs().filter((tab) => controller.status(tab.sessionID).busy)
const candidates = tabs().filter((tab) => {
const status = controller.status(tab.sessionID)
return status.busy && !status.attention
})
return candidates[Math.floor(Math.random() * candidates.length)]
}
// A fake transcript for the selected session so tab switches feel like moving between real
@ -190,7 +217,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
{ text: `> ${fixture.title}`, color: theme.text.default },
{ text: "", color: theme.text.default },
]
if (!status.busy && outcome === undefined) {
if (!status.busy && !status.attention && outcome === undefined) {
lines.push({ text: "no activity yet — press s to run this session", color: theme.text.subdued })
return lines
}
@ -202,7 +229,11 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
{ text: ` ✱ Bash bun run test`, color: theme.text.subdued },
{ text: "", color: theme.text.default },
)
if (status.busy) lines.push({ text: "● Working…", color: theme.text.subdued })
if (status.attention === "question")
lines.push({ text: "? Which approach should I take?", color: theme.text.status.question })
else if (status.attention === "permission")
lines.push({ text: "! Waiting for permission to run the command", color: theme.text.status.permission })
else if (status.busy) lines.push({ text: "● Working…", color: theme.text.status.running })
else if (outcome === "failed")
lines.push({
text: `✗ bun run test failed — 3 tests failing in ${file}`,
@ -218,9 +249,26 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const stateSummary = () => {
const values = tabs().map((tab) => controller.status(tab.sessionID))
const running = values.filter((status) => status.busy).length
const running = values.filter((status) => status.busy && !status.attention).length
const waiting = values.filter((status) => status.attention).length
const unread = values.filter((status) => status.unread !== undefined).length
return [`selected ${number(active() ?? "")}`, `${running} running`, `${unread} unread`].join(" · ")
return [`selected ${number(active() ?? "")}`, `${running} running`, `${waiting} waiting`, `${unread} unread`].join(
" · ",
)
}
const reset = (showcase = false) => {
batch(() => {
setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })))
setStatuses(showcase ? FIXTURE_STATUSES : {})
setOutcomes(showcase ? FIXTURE_OUTCOMES : {})
setActive("fixture-1")
setSpinner("dots")
setMarker("small-dot")
setAnimations(true)
setOrientation("vertical")
})
setLastEvent(showcase ? "all six states are visible" : "reset; all tabs idle")
}
props.context.keymap.layer(() => ({
@ -236,7 +284,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
{ bind: "up,k,left,h", title: "Previous tab", group: "Storybook", run: () => cycle(-1) },
{ bind: "down,j,right,l", title: "Next tab", group: "Storybook", run: () => cycle(1) },
...Array.from({ length: 10 }, (_, index) => ({
bind: String((index + 1) % 10),
bind: `${(index + 1) % 10},ctrl+${(index + 1) % 10}`,
title: `Select tab ${index + 1}`,
group: "Storybook",
run() {
@ -289,13 +337,64 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
run() {
const current = active()
if (!current) return
if (controller.status(current).busy) {
if (controller.status(current).busy && !controller.status(current).attention) {
setLastEvent(`tab ${number(current)} is already running`)
return
}
startRun(current)
},
},
...(
[
{ bind: "q", title: "Ask a question", attention: "question" },
{ bind: "a", title: "Request permission", attention: "permission" },
{ bind: "i", title: "Set idle", attention: false },
] as const
).map((state) => ({
bind: state.bind,
title: state.title,
group: "Storybook",
run() {
const current = active()
if (!current) return
startRun(current)
setStatuses((statuses) => ({
...statuses,
[current]: { ...EMPTY_SESSION_TAB_STATUS, busy: Boolean(state.attention), attention: state.attention },
}))
setLastEvent(`tab ${number(current)} ${state.attention || "idle"}`)
},
})),
...(
[
{ bind: "f", title: "Complete selected tab", failed: false },
{ bind: "x", title: "Fail selected tab", failed: true },
] as const
).map((state) => ({
bind: state.bind,
title: state.title,
group: "Storybook",
run() {
const current = active()
if (!current) return
// Leave the result unread so its indicator can be inspected before selecting it again.
cycle(1)
finishRun(current, state.failed)
},
})),
{
bind: "c",
title: "Cycle spinner shape",
group: "Storybook",
run: () => setSpinner((value) => spinners[(spinners.indexOf(value) + 1) % spinners.length]),
},
{
bind: "u",
title: "Cycle unread marker",
group: "Storybook",
run: () => setMarker((value) => markers[(markers.indexOf(value) + 1) % markers.length]),
},
{ bind: "m", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) },
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
{
@ -306,20 +405,8 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
setOrientation((value) => (value === "vertical" ? "horizontal" : "vertical"))
},
},
{
bind: "r",
title: "Reset",
group: "Storybook",
run() {
batch(() => {
setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })))
setStatuses({})
setOutcomes({})
setActive("fixture-1")
})
setLastEvent("reset; press space to start a random tab")
},
},
{ bind: "r,shift+r", title: "Reset to idle", group: "Storybook", run: () => reset() },
{ bind: "v", title: "Show all states", group: "Storybook", run: () => reset(true) },
],
}))
@ -331,7 +418,13 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
backgroundColor={theme.background.default}
>
<box flexGrow={1} flexDirection={orientation() === "vertical" ? "row" : "column"}>
<SessionTabs controller={controller} orientation={orientation()} />
<SessionTabs
controller={controller}
orientation={orientation()}
spinner={spinner()}
unreadMarker={marker()}
animations={animations()}
/>
<box flexGrow={1} paddingLeft={2} paddingRight={2} paddingTop={1} flexDirection="column">
<For each={transcript()}>
{(line) => (
@ -345,16 +438,28 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
<StoryFooter
context={props.context}
title="storybook / tabs"
details={[orientation() === "vertical" ? "left rail" : "top strip"]}
details={[
orientation() === "vertical" ? "left rail" : "top strip",
spinner(),
`${TAB_UNREAD_MARKERS[marker()]} ${marker()}`,
animations() ? "animated" : "still",
]}
status={stateSummary()}
message={lastEvent()}
controls={[
{ shortcut: "space", label: "start" },
{ shortcut: "p", label: "prompt" },
{ shortcut: "e", label: "end" },
{ shortcut: "s", label: "work" },
{ shortcut: "q", label: "question" },
{ shortcut: "a", label: "permission" },
{ shortcut: "i", label: "idle" },
{ shortcut: "f/x", label: "complete/fail" },
{ shortcut: "c", label: "spinner" },
{ shortcut: "u", label: "unread marker" },
{ shortcut: "hold ctrl", label: "numbers" },
{ shortcut: "m", label: "motion" },
{ shortcut: "↑/↓", label: "select" },
{ shortcut: "o", label: "layout" },
{ shortcut: "r", label: "reset" },
{ shortcut: "r", label: "reset idle" },
{ shortcut: "v", label: "all states" },
{ shortcut: "esc", label: "back" },
]}
/>

View file

@ -177,11 +177,15 @@ export function createPluginContext(input: {
tabs: {
enabled: host.sessionTabs.enabled,
list: () =>
host.sessionTabs.tabs().map((tab) => ({
...tab,
active: host.sessionTabs.current() === tab.sessionID,
...host.sessionTabs.status(tab.sessionID),
})),
host.sessionTabs.tabs().map((tab) => {
const status = host.sessionTabs.status(tab.sessionID)
return {
...tab,
active: host.sessionTabs.current() === tab.sessionID,
...status,
attention: Boolean(status.attention),
}
}),
open(sessionID) {
if (!host.sessionTabs.enabled()) return false
host.sessionTabs.select(sessionID)

View file

@ -34,7 +34,9 @@ test("releasing a transcript selection over tab controls does not activate them"
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
<text>selectable transcript text</text>
</box>
</ThemeProvider>
@ -145,7 +147,9 @@ test("double-clicking a preview tab keeps it open without promoting permanent ta
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
@ -190,7 +194,9 @@ test("middle-click closes a session tab without selecting it", async () => {
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
@ -238,7 +244,9 @@ test("keeps consecutive close controls fixed across overflow window changes", as
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
@ -290,7 +298,9 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
<text>outside</text>
</box>
</ThemeProvider>

View file

@ -0,0 +1,254 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { expect, test } from "bun:test"
import { batch, createSignal } from "solid-js"
import { ConfigProvider, useConfig, type Info } from "../../src/config"
import {
EMPTY_SESSION_TAB_STATUS,
SessionTabs,
type SessionTabsController,
type SessionTabsStatus,
} from "../../src/component/session-tabs"
import { SPINNER_FRAMES } from "../../src/component/spinner-frames"
import { ClientProvider } from "../../src/context/client"
import { DataProvider } from "../../src/context/data"
import { LocationProvider } from "../../src/context/location"
import { Keymap } from "../../src/context/keymap"
import { RouteProvider } from "../../src/context/route"
import { TuiAppProvider } from "../../src/context/runtime"
import { SessionTabsProvider } from "../../src/context/session-tabs"
import { StorageProvider } from "../../src/context/storage"
import { ThemeProvider, useTheme } from "../../src/context/theme"
import { emptyThemeSource, tmpdir } 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) {
test(`${orientation} tabs replace ordinals with status without moving titles`, async () => {
await using temporary = await tmpdir()
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
const [active, setActive] = createSignal("second")
const [animations, setAnimations] = createSignal(false)
const [newTab, setNewTab] = createSignal(false)
const settings: Info = { tabs: { enabled: true } }
let config!: ReturnType<typeof useConfig>
let theme!: ReturnType<typeof useTheme>
function Colors() {
config = useConfig()
theme = orientation === "vertical" ? useTheme("elevated") : useTheme()
return null
}
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
],
current: active,
newTab,
select(sessionID: string) {
batch(() => {
setActive(sessionID)
if (sessionID === "first") setStatus((current) => ({ ...current, unread: undefined }))
})
},
close() {},
move() {},
detail: () => "project",
status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS),
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts paths={{ state: temporary.path }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider
config={createTuiResolvedConfig(settings)}
service={{
get: async () => settings,
update: async (update) => {
update(settings)
return settings
},
}}
>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={createApi(createFetch(undefined, createEventStream()).fetch)}>
<DataProvider directory={temporary.path}>
<LocationProvider>
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Colors />
<Keymap.Provider>
<SessionTabs
controller={controller}
orientation={orientation}
animations={animations()}
/>
</Keymap.Provider>
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ConfigProvider>
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
),
{ width: 60, height: 10, kittyKeyboard: true },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes(" First") && frame.includes(" Second"))
const pressed = performance.now()
await app.mockInput.pressKeys(["\x1b[57442;5u"])
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
expect(performance.now() - pressed).toBeGreaterThanOrEqual(300)
await app.mockInput.pressKeys(["\x1b[57442;1:3u"])
await app.waitForFrame((frame) => frame.includes(" First"))
await app.mockInput.pressKeys(["\x1b[57442;5u"])
await app.renderOnce()
await app.mockInput.pressKeys(["\x1b[57442;1:3u"])
await Bun.sleep(325)
await app.renderOnce()
expect(app.captureCharFrame()).toContain(" First")
const titleColumn = app
.captureCharFrame()
.split("\n")
.find((line) => line.includes("First"))!
.indexOf("First")
const states: { status: Partial<SessionTabsStatus>; label: string }[] = [
{ status: { busy: true }, label: SPINNER_FRAMES[0] },
{ status: { busy: true, attention: "question" }, label: "?" },
{ status: { busy: true, attention: "permission" }, label: "!" },
{ status: { unread: "activity" }, label: "\u2022" },
{ status: { unread: "error" }, label: "\u2022" },
{ status: {}, label: "" },
]
for (const state of states) {
setStatus({ ...EMPTY_SESSION_TAB_STATUS, ...state.status })
await app.renderOnce()
await app.waitForFrame((frame) => frame.includes(`${state.label.padStart(2)} First`))
const rows = app.captureCharFrame().split("\n")
expect(rows.find((line) => line.includes("First"))!.indexOf("First")).toBe(titleColumn)
expect(rows[orientation === "vertical" ? 2 : 1]?.trim()).toBe(orientation === "vertical" ? "project" : "")
}
for (const attention of ["question", "permission"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
await app.renderOnce()
const glow = () => {
const colors = app
.captureSpans()
.lines[
orientation === "vertical" ? 1 : 0
]!.spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg))
return (
Math.abs(colors[1]!.r - colors[18]!.r) +
Math.abs(colors[1]!.g - colors[18]!.g) +
Math.abs(colors[1]!.b - colors[18]!.b)
)
}
const full = glow()
expect(full).toBeGreaterThan(0)
setActive("first")
await app.renderOnce()
const dim = glow()
expect(dim).toBeGreaterThan(0)
expect(dim).toBeLessThan(full)
setActive("second")
await app.renderOnce()
setAnimations(true)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
setActive("first")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
setActive("second")
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
await app.waitForFrame(() => glow() > dim && glow() < full)
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
setStatus(EMPTY_SESSION_TAB_STATUS)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
}
const glyph = "\u2022"
for (const unread of ["activity", "error"] as const) {
setAnimations(false)
setActive("second")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
setAnimations(true)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
await app.renderOnce()
const color = () =>
app
.captureSpans()
.lines.flatMap((line) => line.spans)
.find((span) => span.text.trim() === glyph)?.fg
expect(color()?.toInts()).toEqual(
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
)
const brightness = () => {
const value = color()
return value ? value.r + value.g + value.b : undefined
}
const initial = brightness()!
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
await app.renderOnce()
expect(active()).toBe("first")
expect(status().unread).toBeUndefined()
expect(app.captureCharFrame()).toContain(`${glyph} First`)
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
const peak = brightness()!
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
}
setAnimations(false)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
await app.renderOnce()
setAnimations(true)
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
await app.mockInput.pressKeys(["\x1b[57442;5u"])
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
await app.waitForFrame((frame) => frame.includes("1 First"))
app.renderer.emit("blur")
await app.waitForFrame((frame) => frame.includes("? First"))
await config.update((draft) => {
draft.tabs.indicators = "numbers"
})
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
await app.mockInput.pressKeys(["\x1b[57442;5u", "\x1b[57442;1:3u"])
app.renderer.emit("blur")
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
await app.renderOnce()
expect(app.captureCharFrame()).toContain("1 First")
await config.update((draft) => {
draft.tabs.indicators = "status"
})
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
setNewTab(true)
await app.waitForFrame((frame) => frame.includes("+ New session"))
} finally {
app.renderer.destroy()
}
})
}

View file

@ -20,9 +20,10 @@ test("validates mini replay settings", () => {
test("validates the session tabs setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
tabs: { enabled: true, layout: "vertical" },
expect(decode({ tabs: { enabled: true, layout: "vertical", indicators: "numbers" } })).toEqual({
tabs: { enabled: true, layout: "vertical", indicators: "numbers" },
})
expect(() => decode({ tabs: { indicators: "unknown" } })).toThrow()
expect(() => decode({ tabs: { layout: true } })).toThrow()
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
@ -49,7 +50,7 @@ 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" })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal", indicators: "status" })
expect(config.session.new_location).toBe("launch")
expect(config.session.tps).toBe(true)
})
@ -58,6 +59,10 @@ 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.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({
default: "status",
values: ["status", "numbers"],
})
})
test("shows the new session location default in settings", () => {

View file

@ -753,6 +753,64 @@ test("ignores subagent unread state on the root tab", async () => {
}
})
test("distinguishes family questions and permissions without clearing them on selection", async () => {
const setup = await renderSessionTabs("root", {
home: true,
persisted: ["root"],
sessionParents: { child: "root" },
})
try {
await wait(() => setup.data.session.get("child") !== undefined)
expect(setup.tabs.status("root").attention).toBe(false)
setup.emit({
id: "evt_question",
created: 1,
type: "form.created",
data: {
form: {
id: "frm_question",
sessionID: "child",
title: "Choose an approach",
fields: [{ key: "approach", type: "string", title: "Approach" }],
},
},
})
await wait(() => setup.tabs.status("root").attention === "question")
setup.tabs.select("root")
await wait(() => setup.tabs.current() === "root")
expect(setup.tabs.status("root").attention).toBe("question")
setup.emit({
id: "evt_permission",
created: 2,
type: "permission.asked",
data: { id: "per_command", sessionID: "root", action: "shell", resources: ["bun run test"] },
})
await wait(() => setup.tabs.status("root").attention === "permission")
expect(setup.tabs.status("child").attention).toBe("permission")
setup.emit({
id: "evt_permission_reply",
created: 3,
type: "permission.replied",
data: { sessionID: "root", requestID: "per_command", reply: "once" },
})
await wait(() => setup.tabs.status("root").attention === "question")
setup.emit({
id: "evt_question_reply",
created: 4,
type: "form.replied",
data: { sessionID: "child", id: "frm_question", answer: {} },
})
await wait(() => setup.tabs.status("root").attention === false)
} finally {
await setup.destroy()
}
})
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
await using temporary = await tmpdir()
const state = temporary.path

View file

@ -0,0 +1,133 @@
/** @jsxImportSource @opentui/solid */
import type { TextareaRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup, type JSX } from "solid-js"
import { ConfigProvider } from "../src/config"
import { Keymap } from "../src/context/keymap"
import { handleSelectionKey } from "../src/util/selection"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
function renderKeymap(Content: () => JSX.Element) {
return testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ keybinds: { leader: "ctrl+x" } })}>
<Keymap.Provider>
<Content />
</Keymap.Provider>
</ConfigProvider>
),
{ width: 30, height: 5, useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true } },
)
}
test("Kitty Ctrl down preserves transcript selection for Ctrl+C instead of exiting", async () => {
const writes: string[] = []
let exits = 0
const app = await renderKeymap(() => {
const renderer = useRenderer()
const keymap = Keymap.use()
onCleanup(
keymap.intercept(
"key",
({ event }) =>
handleSelectionKey(renderer, { show() {}, error() {} }, event, {
read: async () => undefined,
async write(text) {
writes.push(text)
},
}),
{ priority: 1 },
),
)
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "app.exit", run: () => void exits++ }],
}))
return <text>alpha beta gamma</text>
})
try {
await app.renderOnce()
await app.mockMouse.click(6, 0)
await app.mockMouse.click(6, 0)
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
await app.mockInput.pressKeys(["\x1b[57442;5u"])
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
await app.mockInput.pressKeys(["\x1b[99;5u", "\x1b[99;5:3u", "\x1b[57442;1:3u"])
expect(writes).toEqual(["beta"])
expect(exits).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("Kitty modifier-only events preserve a pending shifted leader sequence", async () => {
let calls = 0
let leader = () => false
const app = await renderKeymap(() => {
leader = Keymap.useLeaderActive()
Keymap.createLayer(() => ({
commands: [{ bind: "<leader>shift+r", run: () => void calls++ }],
}))
return <box />
})
try {
await app.mockInput.pressKeys(["\x1b[57442;5u", "\x1b[120;5u"])
expect(leader()).toBeTrue()
for (const code of [57453, 57454]) {
await app.mockInput.pressKeys([`\x1b[${code}u`, `\x1b[${code};1:3u`])
expect(leader()).toBeTrue()
}
await app.mockInput.pressKeys(["\x1b[120;5:3u", "\x1b[57442;1:3u", "\x1b[57441;2u"])
expect(leader()).toBeTrue()
await app.mockInput.pressKeys(["\x1b[114:82;2u", "\x1b[114:82;2:3u", "\x1b[57441;1:3u"])
expect(calls).toBe(1)
expect(leader()).toBeFalse()
} finally {
app.renderer.destroy()
}
})
test("Kitty associated text preserves Caps Lock and Shift+Caps Lock casing in a textarea", async () => {
let textarea!: TextareaRenderable
const app = await renderKeymap(() => <textarea ref={textarea} focused={true} />)
try {
await app.renderOnce()
await app.mockInput.pressKeys(["\x1b[97;65;65u", "\x1b[97;65:3u"])
expect(textarea.plainText).toBe("A")
await app.mockInput.pressKeys(["\x1b[57441;66u", "\x1b[97;66;97u", "\x1b[97;66:3u", "\x1b[57441;65:3u"])
expect(textarea.plainText).toBe("Aa")
} finally {
app.renderer.destroy()
}
})
test("Control state follows either Kitty Control key, ignores chord releases, and clears on blur", async () => {
let keymap!: Keymap
const app = await renderKeymap(() => {
keymap = Keymap.use()
return <box />
})
try {
expect(keymap.control()).toBeFalse()
for (const code of [57442, 57448]) {
await app.mockInput.pressKeys([`\x1b[${code};5u`])
expect(keymap.control()).toBeTrue()
await app.mockInput.pressKeys(["\x1b[49;5u", "\x1b[49;5:3u"])
expect(keymap.control()).toBeTrue()
await app.mockInput.pressKeys([`\x1b[${code};1:3u`])
expect(keymap.control()).toBeFalse()
await app.mockInput.pressKeys([`\x1b[${code};5u`])
expect(keymap.control()).toBeTrue()
app.renderer.emit("blur")
expect(keymap.control()).toBeFalse()
}
} finally {
app.renderer.destroy()
}
})

View file

@ -156,6 +156,20 @@ Configure diff presentation:
| `single` | boolean | Shows only the selected file patch. |
| `view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses based on the available width. |
## Tabs
Choose **Tabs > Indicators** in settings, or set `tabs.indicators` in `cli.json`:
```json title="cli.json"
{
"tabs": {
"indicators": "numbers"
}
}
```
`status` (the default) shows status icons and reveals tab numbers while Control is held. `numbers` always shows tab numbers, retaining their status colors and background animations. Both modes work with horizontal and vertical tabs.
## Terminal
Configure terminal integration: