From 66f326ddeceadd2fbe92991b72ca1caa8ccec0cb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 26 Aug 2026 21:41:19 -0400 Subject: [PATCH] fix(tui): restore default Kitty keyboard reporting (#45423) --- .../cli/src/services/update-preflight.tsx | 2 +- packages/tui/src/app.tsx | 2 +- packages/tui/src/component/session-tabs.tsx | 15 +- packages/tui/src/config/index.tsx | 2 +- packages/tui/src/context/keymap.tsx | 24 +--- .../system/storybook/session-tabs.tsx | 1 - .../component/session-tabs-mouse.test.tsx | 20 +-- .../component/session-tabs-status.test.tsx | 29 +--- packages/tui/test/keymap-kitty.test.tsx | 133 ------------------ packages/www/src/docs/content/cli/config.mdx | 2 +- 10 files changed, 14 insertions(+), 216 deletions(-) delete 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 4be43974c54..f18f4a8ff3a 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, reportText: true }, + useKittyKeyboard: {}, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], }, diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index e85534f8e46..550787b8d16 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -230,7 +230,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { targetFps: 60, gatherStats: false, exitOnCtrlC: false, - useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true }, + useKittyKeyboard: {}, 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 96677721131..af19d9c98dc 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -464,18 +464,7 @@ export function SessionTabs( 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 ( @@ -485,7 +474,7 @@ export function SessionTabs( animations={props.animations} spinner={props.spinner} unreadMarker={props.unreadMarker} - numbers={numbers()} + numbers={config.tabs.indicators === "numbers"} width={props.width} /> @@ -495,7 +484,7 @@ export function SessionTabs( animations={props.animations} spinner={props.spinner} unreadMarker={props.unreadMarker} - numbers={numbers()} + numbers={config.tabs.indicators === "numbers"} /> diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index b6401b6f221..1845ce54129 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -160,7 +160,7 @@ export const Info = Schema.Struct({ description: "Show tabs in a horizontal strip or vertical sidebar", }), indicators: Schema.optional(Schema.Literals(["status", "numbers"])).annotate({ - description: "Show status icons with numbers on held Control, or always show tab numbers", + description: "Show status icons or always show tab numbers", }), }), ).annotate({ description: "Tab strip settings" }), diff --git a/packages/tui/src/context/keymap.tsx b/packages/tui/src/context/keymap.tsx index db436a2fdd7..21d7108ff45 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, createSignal, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js" +import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js" import { useConfig } from "../config" import { TuiKeybind } from "../config/keybind" @@ -46,7 +46,6 @@ 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 }>() @@ -56,23 +55,6 @@ 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 @@ -163,7 +145,6 @@ function Provider(props: ParentProps<{ config?: KeymapConfig }>) { keymap, config, mode, - control, dispatch, input: (id) => (invocation?.id === id ? invocation.input : undefined), }} @@ -177,8 +158,6 @@ 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. */ @@ -203,7 +182,6 @@ 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/feature-plugins/system/storybook/session-tabs.tsx b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx index 579aad507fa..c1e7d657e98 100644 --- a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx @@ -454,7 +454,6 @@ function SessionTabsStory(props: { context: Plugin.Context }) { { shortcut: "f/x", label: "complete/fail" }, { shortcut: "c", label: "spinner" }, { shortcut: "u", label: "unread marker" }, - { shortcut: "hold ctrl", label: "numbers" }, { shortcut: "m", label: "motion" }, { shortcut: "↑/↓", label: "select" }, { shortcut: "o", label: "layout" }, diff --git a/packages/tui/test/component/session-tabs-mouse.test.tsx b/packages/tui/test/component/session-tabs-mouse.test.tsx index ff55afbac2a..51ba6ccabf0 100644 --- a/packages/tui/test/component/session-tabs-mouse.test.tsx +++ b/packages/tui/test/component/session-tabs-mouse.test.tsx @@ -34,9 +34,7 @@ test("releasing a transcript selection over tab controls does not activate them" - - - + selectable transcript text @@ -147,9 +145,7 @@ test("double-clicking a preview tab keeps it open without promoting permanent ta - - - + @@ -194,9 +190,7 @@ test("middle-click closes a session tab without selecting it", async () => { - - - + @@ -244,9 +238,7 @@ test("keeps consecutive close controls fixed across overflow window changes", as - - - + @@ -298,9 +290,7 @@ 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 index 55cca49cdad..1e3eeebe6f7 100644 --- a/packages/tui/test/component/session-tabs-status.test.tsx +++ b/packages/tui/test/component/session-tabs-status.test.tsx @@ -13,7 +13,6 @@ 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" @@ -79,13 +78,7 @@ for (const orientation of ["horizontal", "vertical"] as const) { - - - + @@ -97,25 +90,12 @@ for (const orientation of ["horizontal", "vertical"] as const) { ), - { width: 60, height: 10, kittyKeyboard: true }, + { width: 60, height: 10 }, ) 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() @@ -225,18 +205,13 @@ for (const orientation of ["horizontal", "vertical"] as const) { 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") diff --git a/packages/tui/test/keymap-kitty.test.tsx b/packages/tui/test/keymap-kitty.test.tsx deleted file mode 100644 index 03593003e2a..00000000000 --- a/packages/tui/test/keymap-kitty.test.tsx +++ /dev/null @@ -1,133 +0,0 @@ -/** @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(() =>