diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index f18f4a8ff3aa..4be43974c547 100644 --- a/packages/cli/src/services/update-preflight.tsx +++ b/packages/cli/src/services/update-preflight.tsx @@ -94,7 +94,7 @@ async function open(from?: string): Promise { screenMode: "split-footer", footerHeight: 4, targetFps: 60, - useKittyKeyboard: {}, + useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true }, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], }, diff --git a/packages/theme/package.json b/packages/theme/package.json index aa54b64232cb..2411ddf22c2b 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -21,6 +21,7 @@ }, "scripts": { "build": "bun run script/build.ts", + "test": "bun test --timeout 30000", "typecheck": "tsgo --noEmit" }, "dependencies": { diff --git a/packages/theme/src/tui/defaults.ts b/packages/theme/src/tui/defaults.ts index 1a09efe39918..9c6cb1cee1bd 100644 --- a/packages/theme/src/tui/defaults.ts +++ b/packages/theme/src/tui/defaults.ts @@ -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" }, diff --git a/packages/theme/src/tui/fallback.ts b/packages/theme/src/tui/fallback.ts index 20325a72e403..1b738df08f35 100644 --- a/packages/theme/src/tui/fallback.ts +++ b/packages/theme/src/tui/fallback.ts @@ -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: { diff --git a/packages/theme/src/tui/resolve.ts b/packages/theme/src/tui/resolve.ts index 835dc3dcbc78..2d3860369b78 100644 --- a/packages/theme/src/tui/resolve.ts +++ b/packages/theme/src/tui/resolve.ts @@ -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({ diff --git a/packages/theme/src/tui/schema.ts b/packages/theme/src/tui/schema.ts index 8395ee914dd1..4d5387a75a81 100644 --- a/packages/theme/src/tui/schema.ts +++ b/packages/theme/src/tui/schema.ts @@ -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), diff --git a/packages/theme/src/tui/types.ts b/packages/theme/src/tui/types.ts index ceb3c151c4bb..1b821f0b5826 100644 --- a/packages/theme/src/tui/types.ts +++ b/packages/theme/src/tui/types.ts @@ -30,6 +30,12 @@ export type ResolvedThemeTokens = { readonly subdued: RGBA readonly action: Readonly> readonly formfield: FormfieldColor + readonly status: { + readonly running: RGBA + readonly question: RGBA + readonly permission: RGBA + readonly unread: RGBA + } readonly feedback: Readonly> } readonly background: { diff --git a/packages/theme/test/status.test.ts b/packages/theme/test/status.test.ts new file mode 100644 index 000000000000..f5308b69b933 --- /dev/null +++ b/packages/theme/test/status.test.ts @@ -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() + } +}) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 3e633056dc61..00ea9cc2a426 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -231,7 +231,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, diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index ab83654ce1c5..d7b72827432f 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -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", diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 7ebb70614e09..966777211317 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -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) { + 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((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 ( + + + {label()} + + } + > + + + + ) +} + 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 ( - + - + ) } -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} /> - - {sessionTabNumberLabel(index()).padStart(numberWidth())} - + /> 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} /> - - {(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())} - + 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 /** 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, } diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 84a761dcace5..6dbade44c130 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -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), } } diff --git a/packages/tui/src/feature-plugins/system/storybook/footer.tsx b/packages/tui/src/feature-plugins/system/storybook/footer.tsx index 24fa26026937..722fc23c21a0 100644 --- a/packages/tui/src/feature-plugins/system/storybook/footer.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/footer.tsx @@ -26,26 +26,23 @@ export function StoryFooter(props: { - {props.status ?? ""} - - · - - {props.message ?? ""} + + {props.status ?? ""} + + {props.status && props.message ? " · " : ""} + {props.message ?? ""} + + - - - - {(control, index) => ( - <> - 0}> - - - {control.shortcut} {control.label} - - )} - - + + + {(control) => ( + + {control.shortcut} {control.label} + + )} + {/* The app-wide feature footer overlays the terminal's final row. */} diff --git a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx index 3f29f86f4e5b..579aad507fa7 100644 --- a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx @@ -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 = { + "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("fixture-1") - const [lastEvent, setLastEvent] = createSignal("press space to start a random tab") - const [statuses, setStatuses] = createSignal>({}) + const [lastEvent, setLastEvent] = createSignal("idle / working / question / permission / complete / error") + const [statuses, setStatuses] = createSignal>(FIXTURE_STATUSES) const [orientation, setOrientation] = createSignal<"horizontal" | "vertical">("vertical") + const spinners = Object.keys(TAB_SPINNERS) as TabSpinner[] + const [spinner, setSpinner] = createSignal("dots") + const markers = Object.keys(TAB_UNREAD_MARKERS) as TabUnreadMarker[] + const [marker, setMarker] = createSignal("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>({}) + const [outcomes, setOutcomes] = createSignal>(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} > - + {(line) => ( @@ -345,16 +438,28 @@ function SessionTabsStory(props: { context: Plugin.Context }) { diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx index 8a8340d23232..d4b226d2c8a7 100644 --- a/packages/tui/src/plugin/api.tsx +++ b/packages/tui/src/plugin/api.tsx @@ -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) diff --git a/packages/tui/test/component/session-tabs-mouse.test.tsx b/packages/tui/test/component/session-tabs-mouse.test.tsx index 51ba6ccabf05..ff55afbac2aa 100644 --- a/packages/tui/test/component/session-tabs-mouse.test.tsx +++ b/packages/tui/test/component/session-tabs-mouse.test.tsx @@ -34,7 +34,9 @@ test("releasing a transcript selection over tab controls does not activate them" - + + + selectable transcript text @@ -145,7 +147,9 @@ test("double-clicking a preview tab keeps it open without promoting permanent ta - + + + @@ -190,7 +194,9 @@ test("middle-click closes a session tab without selecting it", async () => { - + + + @@ -238,7 +244,9 @@ test("keeps consecutive close controls fixed across overflow window changes", as - + + + @@ -290,7 +298,9 @@ test("reflows held tabs when the pointer leaves the strip", async () => { - + + + outside 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..55cca49cdad8 --- /dev/null +++ b/packages/tui/test/component/session-tabs-status.test.tsx @@ -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(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 + let theme!: ReturnType + 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( + () => ( + + + + settings, + update: async (update) => { + update(settings) + return settings + }, + }} + > + + + + + + + + + + + + + + + + + + + + + ), + { 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; 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() + } + }) +} diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 19685310418e..cb3f732f2a8a 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -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", () => { diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 280a24b9ce42..d8c0455f9db2 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -752,6 +752,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 diff --git a/packages/tui/test/keymap-kitty.test.tsx b/packages/tui/test/keymap-kitty.test.tsx new file mode 100644 index 000000000000..03593003e2ad --- /dev/null +++ b/packages/tui/test/keymap-kitty.test.tsx @@ -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( + () => ( + + + + + + ), + { 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 alpha beta gamma + }) + + 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: "shift+r", run: () => void calls++ }], + })) + return + }) + + 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(() =>