From ff638de2aae09a771c4adaccb51aef54c1fbb729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=81=E9=A3=9E=28=E6=80=80=E5=8F=98=29?= Date: Fri, 6 Mar 2026 17:13:49 +0800 Subject: [PATCH] fix(tui): ignore lock/private-use key events in text inputs --- .../cli/cmd/tui/component/prompt/index.tsx | 2 ++ .../cli/cmd/tui/routes/session/permission.tsx | 4 +++ .../cli/cmd/tui/routes/session/question.tsx | 4 +++ .../cli/cmd/tui/ui/dialog-export-options.tsx | 4 +++ .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 4 +++ .../src/cli/cmd/tui/ui/dialog-select.tsx | 4 +++ .../src/cli/cmd/tui/util/input-filter.ts | 25 ++++++++++++++++ .../test/cli/tui/input-filter.test.ts | 29 +++++++++++++++++++ 8 files changed, 76 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/tui/util/input-filter.ts create mode 100644 packages/opencode/test/cli/tui/input-filter.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d63c248fb83..ef9c5556e0e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -34,6 +34,7 @@ import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +import { guard } from "../../util/input-filter" export type PromptProps = { sessionID?: string @@ -836,6 +837,7 @@ export function Prompt(props: PromptProps) { e.preventDefault() return } + if (guard(e)) return // Handle clipboard paste (Ctrl+V) - check for images first on Windows // This is needed because Windows terminal doesn't properly send image data // through bracketed paste, so we need to intercept the keypress and diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc84..8d309701e09 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -16,6 +16,7 @@ import { Locale } from "@/util/locale" import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { useTuiConfig } from "../../context/tui-config" +import { guard } from "../../util/input-filter" type PermissionStage = "permission" | "always" | "reject" @@ -523,6 +524,9 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( focusedTextColor={theme.text} cursorColor={theme.primary} keyBindings={textareaKeybindings()} + onKeyDown={(evt) => { + guard(evt) + }} /> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 1565a300818..b042de8886a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -9,6 +9,7 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useDialog } from "../../ui/dialog" +import { guard } from "../../util/input-filter" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() @@ -385,6 +386,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { val.gotoLineEnd() }) }} + onKeyDown={(evt) => { + guard(evt) + }} initialValue={input()} placeholder="Type your own answer" minHeight={1} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 1e8d09bb0be..572ec6b6853 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -4,6 +4,7 @@ import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { onMount, Show, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" +import { guard } from "../util/input-filter" export type DialogExportOptionsProps = { defaultFilename: string @@ -98,6 +99,9 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { openWithoutSaving: store.openWithoutSaving, }) }} + onKeyDown={(evt) => { + guard(evt) + }} height={3} keyBindings={[{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => (textarea = val)} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index b1b05a0f1a2..c3db6eb92fa 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -3,6 +3,7 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { onMount, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" +import { guard } from "../util/input-filter" export type DialogPromptProps = { title: string @@ -49,6 +50,9 @@ export function DialogPrompt(props: DialogPromptProps) { onSubmit={() => { props.onConfirm?.(textarea.plainText) }} + onKeyDown={(evt) => { + guard(evt) + }} height={3} keyBindings={[{ name: "return", action: "submit" }]} ref={(val: TextareaRenderable) => (textarea = val)} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 151f73cf7c0..4d411140e6f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -8,6 +8,7 @@ import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" +import { guard } from "@tui/util/input-filter" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" @@ -242,6 +243,9 @@ export function DialogSelect(props: DialogSelectProps) { { + guard(evt) + }} onInput={(e) => { batch(() => { setStore("filter", e) diff --git a/packages/opencode/src/cli/cmd/tui/util/input-filter.ts b/packages/opencode/src/cli/cmd/tui/util/input-filter.ts new file mode 100644 index 00000000000..0a8ad09acb8 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/input-filter.ts @@ -0,0 +1,25 @@ +import type { KeyEvent, ParsedKey } from "@opentui/core" + +const LOCK = new Set(["capslock", "numlock", "scrolllock"]) + +export function privateuse(name: string) { + const chars = [...name] + if (chars.length !== 1) return false + const code = chars[0].codePointAt(0) + if (code === undefined) return false + if (code >= 0xe000 && code <= 0xf8ff) return true + if (code >= 0xf0000 && code <= 0xffffd) return true + return code >= 0x100000 && code <= 0x10fffd +} + +export function drop(key: Pick) { + if (!key.name) return false + if (LOCK.has(key.name.toLowerCase())) return true + return privateuse(key.name) +} + +export function guard(key: Pick) { + if (!drop(key)) return false + key.preventDefault() + return true +} diff --git a/packages/opencode/test/cli/tui/input-filter.test.ts b/packages/opencode/test/cli/tui/input-filter.test.ts new file mode 100644 index 00000000000..ad374fdcb3f --- /dev/null +++ b/packages/opencode/test/cli/tui/input-filter.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { drop, privateuse } from "../../../src/cli/cmd/tui/util/input-filter" + +describe("input filter", () => { + test("drops private use key events", () => { + expect(privateuse("\uE00E")).toBe(true) + expect(drop({ name: "\uE00E" })).toBe(true) + expect(privateuse("\u{F0000}")).toBe(true) + expect(drop({ name: "\u{F0000}" })).toBe(true) + }) + + test("keeps ascii and chinese text", () => { + expect(drop({ name: "a" })).toBe(false) + expect(drop({ name: "你" })).toBe(false) + }) + + test("keeps control and navigation keys", () => { + expect(drop({ name: "return" })).toBe(false) + expect(drop({ name: "backspace" })).toBe(false) + expect(drop({ name: "left" })).toBe(false) + expect(drop({ name: "space" })).toBe(false) + }) + + test("drops lock keys", () => { + expect(drop({ name: "capslock" })).toBe(true) + expect(drop({ name: "numlock" })).toBe(true) + expect(drop({ name: "scrolllock" })).toBe(true) + }) +})