fix(tui): restore default Kitty keyboard reporting (#45423)

This commit is contained in:
Kit Langton
2026-08-26 21:41:19 -04:00
committed by GitHub
parent 83b81b39f1
commit 66f326ddec
10 changed files with 14 additions and 216 deletions
@@ -94,7 +94,7 @@ async function open(from?: string): Promise<Session> {
screenMode: "split-footer",
footerHeight: 4,
targetFps: 60,
useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true },
useKittyKeyboard: {},
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
},
+1 -1
View File
@@ -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,
+2 -13
View File
@@ -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 (
<Switch>
@@ -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}
/>
</Match>
@@ -495,7 +484,7 @@ export function SessionTabs(
animations={props.animations}
spinner={props.spinner}
unreadMarker={props.unreadMarker}
numbers={numbers()}
numbers={config.tabs.indicators === "numbers"}
/>
</Match>
</Switch>
+1 -1
View File
@@ -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" }),
+1 -23
View File
@@ -13,7 +13,7 @@ import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { useRenderer } from "@opentui/solid"
import { createContext, 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<boolean>
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<boolean>
/** 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,
}
@@ -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" },
@@ -34,9 +34,7 @@ test("releasing a transcript selection over tab controls does not activate them"
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
<text>selectable transcript text</text>
</box>
</ThemeProvider>
@@ -147,9 +145,7 @@ test("double-clicking a preview tab keeps it open without promoting permanent ta
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
@@ -194,9 +190,7 @@ test("middle-click closes a session tab without selecting it", async () => {
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
@@ -244,9 +238,7 @@ test("keeps consecutive close controls fixed across overflow window changes", as
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
@@ -298,9 +290,7 @@ test("reflows held tabs when the pointer leaves the strip", async () => {
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
</Keymap.Provider>
<SessionTabs controller={controller} animations={false} />
<text>outside</text>
</box>
</ThemeProvider>
@@ -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) {
<SessionTabsProvider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<Colors />
<Keymap.Provider>
<SessionTabs
controller={controller}
orientation={orientation}
animations={animations()}
/>
</Keymap.Provider>
<SessionTabs controller={controller} orientation={orientation} animations={animations()} />
</ThemeProvider>
</SessionTabsProvider>
</LocationProvider>
@@ -97,25 +90,12 @@ for (const orientation of ["horizontal", "vertical"] as const) {
</TuiAppProvider>
</TestTuiContexts>
),
{ 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")
-133
View File
@@ -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(
() => (
<ConfigProvider config={createTuiResolvedConfig({ keybinds: { leader: "ctrl+x" } })}>
<Keymap.Provider>
<Content />
</Keymap.Provider>
</ConfigProvider>
),
{ width: 30, height: 5, useKittyKeyboard: { events: true, allKeysAsEscapes: true, reportText: true } },
)
}
test("Kitty Ctrl down preserves transcript selection for Ctrl+C instead of exiting", async () => {
const writes: string[] = []
let exits = 0
const app = await renderKeymap(() => {
const renderer = useRenderer()
const keymap = Keymap.use()
onCleanup(
keymap.intercept(
"key",
({ event }) =>
handleSelectionKey(renderer, { show() {}, error() {} }, event, {
read: async () => undefined,
async write(text) {
writes.push(text)
},
}),
{ priority: 1 },
),
)
Keymap.createLayer(() => ({
mode: "global",
commands: [{ id: "app.exit", run: () => void exits++ }],
}))
return <text>alpha beta gamma</text>
})
try {
await app.renderOnce()
await app.mockMouse.click(6, 0)
await app.mockMouse.click(6, 0)
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
await app.mockInput.pressKeys(["\x1b[57442;5u"])
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
await app.mockInput.pressKeys(["\x1b[99;5u", "\x1b[99;5:3u", "\x1b[57442;1:3u"])
expect(writes).toEqual(["beta"])
expect(exits).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("Kitty modifier-only events preserve a pending shifted leader sequence", async () => {
let calls = 0
let leader = () => false
const app = await renderKeymap(() => {
leader = Keymap.useLeaderActive()
Keymap.createLayer(() => ({
commands: [{ bind: "<leader>shift+r", run: () => void calls++ }],
}))
return <box />
})
try {
await app.mockInput.pressKeys(["\x1b[57442;5u", "\x1b[120;5u"])
expect(leader()).toBeTrue()
for (const code of [57453, 57454]) {
await app.mockInput.pressKeys([`\x1b[${code}u`, `\x1b[${code};1:3u`])
expect(leader()).toBeTrue()
}
await app.mockInput.pressKeys(["\x1b[120;5:3u", "\x1b[57442;1:3u", "\x1b[57441;2u"])
expect(leader()).toBeTrue()
await app.mockInput.pressKeys(["\x1b[114:82;2u", "\x1b[114:82;2:3u", "\x1b[57441;1:3u"])
expect(calls).toBe(1)
expect(leader()).toBeFalse()
} finally {
app.renderer.destroy()
}
})
test("Kitty associated text preserves Caps Lock and Shift+Caps Lock casing in a textarea", async () => {
let textarea!: TextareaRenderable
const app = await renderKeymap(() => <textarea ref={textarea} focused={true} />)
try {
await app.renderOnce()
await app.mockInput.pressKeys(["\x1b[97;65;65u", "\x1b[97;65:3u"])
expect(textarea.plainText).toBe("A")
await app.mockInput.pressKeys(["\x1b[57441;66u", "\x1b[97;66;97u", "\x1b[97;66:3u", "\x1b[57441;65:3u"])
expect(textarea.plainText).toBe("Aa")
} finally {
app.renderer.destroy()
}
})
test("Control state follows either Kitty Control key, ignores chord releases, and clears on blur", async () => {
let keymap!: Keymap
const app = await renderKeymap(() => {
keymap = Keymap.use()
return <box />
})
try {
expect(keymap.control()).toBeFalse()
for (const code of [57442, 57448]) {
await app.mockInput.pressKeys([`\x1b[${code};5u`])
expect(keymap.control()).toBeTrue()
await app.mockInput.pressKeys(["\x1b[49;5u", "\x1b[49;5:3u"])
expect(keymap.control()).toBeTrue()
await app.mockInput.pressKeys([`\x1b[${code};1:3u`])
expect(keymap.control()).toBeFalse()
await app.mockInput.pressKeys([`\x1b[${code};5u`])
expect(keymap.control()).toBeTrue()
app.renderer.emit("blur")
expect(keymap.control()).toBeFalse()
}
} finally {
app.renderer.destroy()
}
})
+1 -1
View File
@@ -168,7 +168,7 @@ Choose **Tabs > Indicators** in settings, or set `tabs.indicators` in `cli.json`
}
```
`status` (the default) shows status icons and reveals tab numbers while Control is held. `numbers` always shows tab numbers, retaining their status colors and background animations. Both modes work with horizontal and vertical tabs.
`status` (the default) shows status icons. `numbers` always shows tab numbers, retaining their status colors and background animations. Both modes work with horizontal and vertical tabs. Ctrl+number shortcuts switch tabs in either mode.
## Terminal