mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:51:21 -04:00
fix(tui): restore default Kitty keyboard reporting (#45423)
This commit is contained in:
@@ -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" }],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user