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)
+ })
+})