From 00681e9a02c38da71b37f6d50bf03747a0d453bc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 13:56:12 -0400 Subject: [PATCH 1/9] feat(tui): make session tab status explicit --- .../cli/src/services/update-preflight.tsx | 2 +- packages/theme/package.json | 1 + packages/theme/src/tui/defaults.ts | 10 + packages/theme/src/tui/fallback.ts | 9 +- packages/theme/src/tui/resolve.ts | 2 +- packages/theme/src/tui/schema.ts | 7 + packages/theme/src/tui/types.ts | 5 + packages/theme/test/status.test.ts | 126 +++++++++++ packages/tui/src/app.tsx | 2 +- packages/tui/src/component/session-tabs.tsx | 195 ++++++++++++++---- packages/tui/src/context/session-tabs.tsx | 8 +- .../system/storybook/footer.tsx | 33 ++- .../system/storybook/session-tabs.tsx | 181 ++++++++++++---- packages/tui/src/plugin/api.tsx | 14 +- .../component/session-tabs-status.test.tsx | 188 +++++++++++++++++ .../tui/test/context/session-tabs.test.tsx | 58 ++++++ 16 files changed, 731 insertions(+), 110 deletions(-) create mode 100644 packages/theme/test/status.test.ts create mode 100644 packages/tui/test/component/session-tabs-status.test.tsx diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index f18f4a8ff3aa..6cff48b5dd90 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 }, 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..250d44e0ecf2 100644 --- a/packages/theme/src/tui/defaults.ts +++ b/packages/theme/src/tui/defaults.ts @@ -121,6 +121,11 @@ 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", + }, feedback: { error: { default: "$hue.red.700", subdued: "$hue.red.600" }, warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" }, @@ -336,6 +341,11 @@ 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", + }, 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..25bf03f55a80 100644 --- a/packages/theme/src/tui/fallback.ts +++ b/packages/theme/src/tui/fallback.ts @@ -1,7 +1,7 @@ -import type { ThemeTokensDefinition } from "./index.js" +import type { Mode, ThemeTokensDefinition } from "./index.js" import { ActionVariant, FeedbackKind } from "./schema.js" -export function fallback(): ThemeTokensDefinition { +export function fallback(mode: Mode): ThemeTokensDefinition { const red = "#ff0000" return { @@ -9,6 +9,11 @@ export function fallback(): ThemeTokensDefinition { default: red, action: Object.fromEntries(ActionVariant.literals.map((variant) => [variant, { default: red }])), formfield: { default: red }, + status: { + running: mode === "light" ? "$hue.interactive.800" : "$hue.interactive.200", + question: "$text.feedback.info.default", + permission: "$text.feedback.warning.default", + }, 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..cfa36ef7e86d 100644 --- a/packages/theme/src/tui/schema.ts +++ b/packages/theme/src/tui/schema.ts @@ -108,6 +108,13 @@ 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), + }), + ), 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..1b008278f4bb 100644 --- a/packages/theme/src/tui/types.ts +++ b/packages/theme/src/tui/types.ts @@ -30,6 +30,11 @@ export type ResolvedThemeTokens = { readonly subdued: RGBA readonly action: Readonly> readonly formfield: FormfieldColor + readonly status: { + readonly running: RGBA + readonly question: RGBA + readonly permission: 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..28931d6ed869 --- /dev/null +++ b/packages/theme/test/status.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { Schema } from "effect" +import { + DEFAULT_THEME, + ThemeDocument, + migrateV1, + resolveTheme, + resolveThemeDocument, + selectTheme, +} from "../src/tui/index.js" +import type { ThemeV1Json } from "../src/tui/v1.js" + +test.each(["light", "dark"] as const)("resolves built-in %s status colors", (mode) => { + const theme = resolveTheme(selectTheme(DEFAULT_THEME, mode)) + + expect(theme.text.status.running).toBe(theme.hue.interactive[mode === "light" ? 800 : 200]) + expect(theme.text.status.question).toBe(theme.text.feedback.info.default) + expect(theme.text.status.question).toBe(theme.hue.cyan[mode === "light" ? 700 : 300]) + expect(theme.text.status.permission).toBe(theme.text.feedback.warning.default) + expect(theme.text.status.permission).toBe(theme.hue.yellow[mode === "light" ? 800 : 200]) + expect(theme.contextual.elevated.text.status).toEqual(theme.text.status) + expect(theme.contextual.overlay.text.status).toEqual(theme.text.status) +}) + +test.each(["light", "dark"] as const)("custom %s themes inherit status colors from their hues and feedback", (mode) => { + const definition = { + hue: { interactive: "$hue.purple" }, + text: { + feedback: { info: { default: "#123456" }, warning: { default: "#654321" } }, + }, + } as const + const theme = resolveThemeDocument({ version: 2, light: definition, dark: definition }, mode) + + expect(theme.text.status.running).toBe(theme.hue.interactive[mode === "light" ? 800 : 200]) + expect(theme.text.status.running.equals(theme.hue.purple[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() +}) + +test("decodes partial status overrides and resolves colors and references", () => { + const document = Schema.decodeUnknownSync(ThemeDocument)({ + version: 2, + light: { text: { status: { question: "#123456" } } }, + dark: { text: { status: { running: "$hue.purple.300", permission: "transparent" } } }, + }) + const light = resolveThemeDocument(document, "light") + const dark = resolveThemeDocument(document, "dark") + + expect(light.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue() + expect(light.text.status.running).toBe(light.hue.interactive[800]) + expect(light.text.status.permission).toBe(light.text.feedback.warning.default) + expect(dark.text.status.running).toBe(dark.hue.purple[300]) + expect(dark.text.status.question).toBe(dark.text.feedback.info.default) + expect(dark.text.status.permission.toInts()).toEqual([0, 0, 0, 0]) + expect(() => + Schema.decodeUnknownSync(ThemeDocument)({ version: 2, light: { text: { status: { running: "opaque" } } } }), + ).toThrow() +}) + +test("contextual status overrides inherit remaining tokens and rewire feedback references", () => { + const theme = resolveThemeDocument({ + version: 2, + dark: { + "@context:elevated": { + text: { + status: { running: "$hue.purple.300" }, + feedback: { info: { default: "#123456" } }, + }, + }, + }, + }) + + expect(theme.contextual.elevated.text.status.running).toBe(theme.hue.purple[300]) + expect(theme.contextual.elevated.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue() + expect(theme.contextual.elevated.text.status.permission).toBe(theme.text.status.permission) + expect(theme.contextual.overlay.text.status).toEqual(theme.text.status) +}) + +test.each(["light", "dark"] as const)( + "standalone %s themes inherit existing semantic colors when status tokens are omitted", + (mode) => { + const definition = { + hue: { ...DEFAULT_THEME[mode].hue, interactive: "$hue.purple" }, + text: { + feedback: { info: { default: "#22d3ee" }, warning: { default: "#eab308" } }, + }, + } as const + const document = { version: 2, standalone: true, light: definition, dark: definition } as const + const theme = resolveThemeDocument(document, mode) + const override = { ...definition, text: { ...definition.text, status: { question: "#123456" } } } as const + const overridden = resolveThemeDocument({ + version: 2, + standalone: true, + ...(mode === "light" ? { light: override } : { dark: override }), + }) + + expect(theme.text.status.running).toBe(theme.hue.interactive[mode === "light" ? 800 : 200]) + expect(theme.text.status.running.equals(theme.hue.purple[mode === "light" ? 800 : 200])).toBeTrue() + expect(theme.text.status.question.equals(RGBA.fromHex("#22d3ee"))).toBeTrue() + expect(theme.text.status.permission.equals(RGBA.fromHex("#eab308"))).toBeTrue() + expect(theme.text.default.toInts()).toEqual([255, 0, 0, 255]) + expect(theme.contextual.elevated.text.status).toEqual(theme.text.status) + expect(overridden.text.status.question.equals(RGBA.fromHex("#123456"))).toBeTrue() + expect(overridden.text.status.running.equals(theme.text.status.running)).toBeTrue() + expect(overridden.text.status.permission.equals(theme.text.status.permission)).toBeTrue() + }, +) + +test.each(["light", "dark"] as const)( + "built-in opencode JSON inherits %s status colors without new tokens", + async (mode) => { + const source: ThemeV1Json = await Bun.file( + new URL("../../tui/src/theme/assets/opencode.json", import.meta.url), + ).json() + const document = migrateV1(source) + const theme = resolveThemeDocument(document, mode) + + expect(document.standalone).toBeTrue() + expect(document[mode]?.text?.status).toBeUndefined() + expect(theme.text.status.running).toBe(theme.hue.interactive[mode === "light" ? 800 : 200]) + 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(Object.values(theme.text.status).every((color) => !color.equals(RGBA.fromHex("#ff0000")))).toBeTrue() + }, +) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 3e633056dc61..9c8252a7621d 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 }, autoFocus: false, openConsoleOnError: false, useMouse: config.mouse, diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 7ebb70614e09..9958b39dff1c 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -12,11 +12,11 @@ import { onCleanup, untrack, } from "solid-js" -import { Portal, useTerminalDimensions } from "@opentui/solid" +import { Portal, useKeyboard, useRenderer, 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,18 @@ 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 = { + arcs: { frames: ["◜", "◝", "◞", "◟"], interval: 120 }, + dots: { frames: SPINNER_FRAMES, interval: 80 }, + quadrants: { frames: ["◴", "◷", "◶", "◵"], interval: 120 }, + line: { frames: ["|", "/", "-", "\\"], interval: 120 }, +} +export type TabSpinner = keyof typeof TAB_SPINNERS // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 @@ -89,6 +101,49 @@ 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 + animations: boolean + numbers: boolean + spinner?: TabSpinner + attributes?: number +}) { + const runs = () => props.status.busy && !props.status.attention + const spinner = () => TAB_SPINNERS[props.spinner ?? "arcs"] + 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.status.unread === "error") return "×" + if (props.status.unread === "activity") return "✓" + return props.label + } + return ( + + + {label()} + + } + > + + + + ) +} + function createPreviewDoubleClick(tabs: SessionTabsController) { let previous: { sessionID: string; time: number } | undefined return (sessionID: string) => { @@ -360,35 +415,67 @@ export function SessionTabs( props: { controller?: SessionTabsController animations?: boolean + spinner?: TabSpinner + statusPosition?: "inline" | "below" orientation?: "horizontal" | "vertical" width?: number } = {}, ) { + const renderer = useRenderer() + const [numbers, setNumbers] = createSignal(false) + useKeyboard( + (key) => { + if (key.name === "leftctrl" || key.name === "rightctrl") setNumbers(key.eventType !== "release") + }, + { release: true }, + ) + const blur = () => setNumbers(false) + renderer.on("blur", blur) + onCleanup(() => renderer.off("blur", blur)) + return ( - + - + ) } -function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean; width?: number }) { +function VerticalSessionTabs(props: { + controller?: SessionTabsController + animations?: boolean + numbers: boolean + spinner?: TabSpinner + statusPosition?: "inline" | "below" + 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 below = () => props.statusPosition === "below" + const accent = () => theme.text.feedback.success.default + 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) @@ -423,7 +510,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat complete: sessionTabComplete(status.unread, status.busy), runs: status.busy && !status.attention, glows: - tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)), + tab.sessionID !== activeID() && + Boolean(status.attention || (!status.busy && status.unread !== undefined)), }, ] as const }), @@ -564,7 +652,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) @@ -578,8 +666,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat // Latched so a resolving glow fades out in the hue it lit with instead of snapping to accent. 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) + const feedback = tabFeedbackColor(status(), theme) + if (feedback) return (lastGlowHue = feedback) if (status().unread !== undefined) return (lastGlowHue = accent()) return lastGlowHue ?? accent() } @@ -622,9 +710,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat 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) + const feedback = tabFeedbackColor(previousStatus(), theme) + if (feedback) return (lastPreviousGlowHue = feedback) if (previousStatus().unread !== undefined) return (lastPreviousGlowHue = accent()) return lastPreviousGlowHue ?? accent() } @@ -755,14 +842,16 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat onLevel={setSweepLevel} /> - - {sessionTabNumberLabel(index()).padStart(numberWidth())} - + /> - + + + + @@ -923,11 +1029,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat ) } -function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) { +function HorizontalSessionTabs(props: { + controller?: SessionTabsController + animations?: boolean + spinner?: TabSpinner + 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 +1070,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 accent = () => theme.text.feedback.success.default + 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 +1320,10 @@ 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 feedbackColor = () => tabFeedbackColor(status(), theme) const glowColor = () => feedbackColor() ?? accent() - const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined)) + const glows = () => + !selected() && Boolean(status().attention || (!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 +1379,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, accent(), 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())) } @@ -1332,9 +1438,16 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim onLevel={setSweepLevel} /> - - {(tab === NEW_SESSION_TAB ? "+" : sessionTabNumberLabel(tabNumber() - 1)).padStart(numberWidth())} - + (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..31ddd0a428ce 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,13 @@ 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, + type SessionTabsController, + type TabSpinner, +} from "../../../component/session-tabs" import { closeSessionTab, cycleSessionTab, moveSessionTab } from "../../../context/session-tabs-model" import { StoryFooter } from "./footer" import type { Story } from "./index" @@ -24,6 +30,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 +60,25 @@ 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 [statusPosition, setStatusPosition] = createSignal<"inline" | "below">("below") + const spinners = Object.keys(TAB_SPINNERS) as TabSpinner[] + const [spinner, setSpinner] = createSignal("arcs") + 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 +94,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 +150,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 +191,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 +214,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 +226,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 +246,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("arcs") + setAnimations(true) + setOrientation("vertical") + setStatusPosition("below") + }) + setLastEvent(showcase ? "all six states are visible" : "reset; all tabs idle") } props.context.keymap.layer(() => ({ @@ -236,7 +281,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,37 +334,79 @@ 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: "t", title: "Add tab", group: "Storybook", run: addTab }, - { bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() }, + ...( + [ + { 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: "o", - title: "Toggle tab orientation", + bind: "c", + title: "Cycle spinner shape", + group: "Storybook", + run: () => setSpinner((value) => spinners[(spinners.indexOf(value) + 1) % spinners.length]), + }, + { bind: "m", title: "Toggle animations", group: "Storybook", run: () => setAnimations((value) => !value) }, + { + bind: "b", + title: "Toggle status below vertical tab titles", group: "Storybook", run() { - setOrientation((value) => (value === "vertical" ? "horizontal" : "vertical")) + setOrientation("vertical") + setStatusPosition((value) => (value === "inline" ? "below" : "inline")) }, }, + { bind: "t", title: "Add tab", group: "Storybook", run: addTab }, + { bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() }, { - bind: "r", - title: "Reset", + bind: "o", + title: "Toggle tab orientation", 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") + setOrientation((value) => (value === "vertical" ? "horizontal" : "vertical")) }, }, + { 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-status.test.tsx b/packages/tui/test/component/session-tabs-status.test.tsx new file mode 100644 index 000000000000..928fd12de78b --- /dev/null +++ b/packages/tui/test/component/session-tabs-status.test.tsx @@ -0,0 +1,188 @@ +/** @jsxImportSource @opentui/solid */ +import { testRender } from "@opentui/solid" +import { expect, test } from "bun:test" +import { createSignal } from "solid-js" +import { ConfigProvider } from "../../src/config" +import { + EMPTY_SESSION_TAB_STATUS, + SessionTabs, + TAB_SPINNERS, + type SessionTabsController, + type SessionTabsStatus, + type TabSpinner, +} from "../../src/component/session-tabs" +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 } 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 [spinner, setSpinner] = createSignal("arcs") + const [statusPosition, setStatusPosition] = createSignal<"inline" | "below">("inline") + const controller = { + tabs: () => [ + { sessionID: "first", title: "First" }, + { sessionID: "second", title: "Second" }, + ], + current: active, + select: setActive, + close() {}, + move() {}, + detail: () => "project", + status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS), + } satisfies SessionTabsController + const app = await testRender( + () => ( + + + + + + + + + + + + + + + + + + + + + + + + ), + { width: 60, height: 10, kittyKeyboard: true }, + ) + + try { + app.renderer.start() + await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second")) + const titleColumn = app + .captureCharFrame() + .split("\n") + .find((line) => line.includes("First"))! + .indexOf("First") + const states: { status: Partial; label: string }[] = [ + { status: { busy: true }, label: TAB_SPINNERS.arcs.frames[0] }, + { status: { busy: true, attention: "question" }, label: "?" }, + { status: { busy: true, attention: "permission" }, label: "!" }, + { status: { unread: "activity" }, label: "\u2713" }, + { status: { unread: "error" }, label: "\u00d7" }, + { status: {}, label: "1" }, + ] + for (const selected of [false, true]) { + setActive(selected ? "first" : "second") + for (const state of states) { + setStatus({ ...EMPTY_SESSION_TAB_STATUS, ...state.status }) + await app.waitForFrame((frame) => frame.includes(`${state.label} First`)) + expect( + app + .captureCharFrame() + .split("\n") + .find((line) => line.includes("First"))! + .indexOf("First"), + ).toBe(titleColumn) + await app.mockInput.pressKeys(["\x1b[57442;5u"]) + await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second")) + // Releasing a chord's digit is not releasing Control. + await app.mockInput.pressKeys(["\x1b[49;5:3u"]) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("1 First") + await app.mockInput.pressKeys(["\x1b[57442;1:3u"]) + await app.waitForFrame((frame) => frame.includes(`${state.label} First`)) + } + } + + setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true }) + setAnimations(true) + for (const name of Object.keys(TAB_SPINNERS) as TabSpinner[]) { + setSpinner(name) + await app.waitForFrame((frame) => TAB_SPINNERS[name].frames.some((glyph) => frame.includes(`${glyph} First`))) + const first = app.captureCharFrame() + await app.waitForFrame((frame) => frame !== first) + await app.mockInput.pressKeys(["\x1b[57448;5u"]) + await app.waitForFrame((frame) => frame.includes("1 First")) + await app.mockInput.pressKeys(["\x1b[57448;1:3u"]) + await app.waitForFrame((frame) => TAB_SPINNERS[name].frames.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")) + + setStatusPosition("below") + if (orientation === "horizontal") { + await app.renderOnce() + expect(app.captureCharFrame().split("\n")[0]).toContain("? First") + expect(app.captureCharFrame().split("\n")[1]?.trim()).toBe("") + } + if (orientation === "vertical") { + setAnimations(false) + setSpinner("arcs") + for (const state of states) { + setStatus({ ...EMPTY_SESSION_TAB_STATUS, ...state.status }) + await app.waitForFrame((frame) => { + const rows = frame.split("\n") + return ( + rows[1]?.includes("1 First") && + rows[2]?.trim() === (state.label === "1" ? "project" : `${state.label} project`) + ) + }) + expect(app.captureCharFrame().split("\n")[1]!.indexOf("First")).toBe(titleColumn) + expect(app.captureCharFrame().split("\n")[4]).toContain("2 Second") + if (state.label !== "1") { + const frame = app.captureSpans() + const number = frame.lines[1]!.spans.find((span) => span.text.trim() === "1") + const icon = frame.lines[2]!.spans.find((span) => span.text.trim() === state.label) + expect(number).toBeDefined() + expect(icon).toBeDefined() + expect(number!.fg.toInts()).toEqual(icon!.fg.toInts()) + } + } + setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" }) + await app.waitForFrame((frame) => frame.split("\n")[2]?.trim() === "? project") + await app.mockInput.pressKeys(["\x1b[57442;5u"]) + await app.renderOnce() + expect(app.captureCharFrame().split("\n")[1]).toContain("1 First") + expect(app.captureCharFrame().split("\n")[2]?.trim()).toBe("? project") + await app.mockInput.pressKeys(["\x1b[57442;1:3u"]) + setActive("second") + await app.mockMouse.click(1, 2) + expect(active()).toBe("first") + setStatusPosition("inline") + await app.waitForFrame( + (frame) => frame.split("\n")[1]?.includes("? First") && frame.split("\n")[2]?.trim() === "project", + ) + } + } finally { + app.renderer.destroy() + } + }) +} 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 From 62fbb2f1446e7a436f6e5425986f243c54e85eac Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 14:03:33 -0400 Subject: [PATCH 2/9] fix(tui): preserve input when reporting modifier keys --- .../cli/src/services/update-preflight.tsx | 2 +- packages/tui/src/app.tsx | 2 +- packages/tui/src/component/session-tabs.tsx | 18 +-- packages/tui/src/context/keymap.tsx | 24 +++- .../component/session-tabs-mouse.test.tsx | 20 ++- packages/tui/test/keymap-kitty.test.tsx | 129 ++++++++++++++++++ 6 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 packages/tui/test/keymap-kitty.test.tsx diff --git a/packages/cli/src/services/update-preflight.tsx b/packages/cli/src/services/update-preflight.tsx index 6cff48b5dd90..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: { events: true, allKeysAsEscapes: true }, + useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true }, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], }, diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9c8252a7621d..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: { events: true, allKeysAsEscapes: true }, + useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true }, autoFocus: false, openConsoleOnError: false, useMouse: config.mouse, diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 9958b39dff1c..fa6b155af61c 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -12,7 +12,7 @@ import { onCleanup, untrack, } from "solid-js" -import { Portal, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { Portal, useTerminalDimensions } from "@opentui/solid" import { useConfig } from "../config" import { useSessionTabs } from "../context/session-tabs" import { useData } from "../context/data" @@ -421,17 +421,7 @@ export function SessionTabs( width?: number } = {}, ) { - const renderer = useRenderer() - const [numbers, setNumbers] = createSignal(false) - useKeyboard( - (key) => { - if (key.name === "leftctrl" || key.name === "rightctrl") setNumbers(key.eventType !== "release") - }, - { release: true }, - ) - const blur = () => setNumbers(false) - renderer.on("blur", blur) - onCleanup(() => renderer.off("blur", blur)) + const keymap = Keymap.use() return ( @@ -441,7 +431,7 @@ export function SessionTabs( animations={props.animations} spinner={props.spinner} statusPosition={props.statusPosition} - numbers={numbers()} + numbers={keymap.control()} width={props.width} /> @@ -450,7 +440,7 @@ export function SessionTabs( controller={props.controller} animations={props.animations} spinner={props.spinner} - numbers={numbers()} + numbers={keymap.control()} /> diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index 21d7108ff458..787fe0417f64 100644 --- a/packages/tui/src/context/keymap.tsx +++ b/packages/tui/src/context/keymap.tsx @@ -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 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)|isolevel[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/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/keymap-kitty.test.tsx b/packages/tui/test/keymap-kitty.test.tsx new file mode 100644 index 000000000000..4afbd2222994 --- /dev/null +++ b/packages/tui/test/keymap-kitty.test.tsx @@ -0,0 +1,129 @@ +/** @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() + 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(() =>