From fcb658a54e56a71be38380e4918c858ce212b65d Mon Sep 17 00:00:00 2001 From: "mike.wang" Date: Wed, 8 Apr 2026 10:44:18 +0800 Subject: [PATCH 1/3] fix: restore terminal state on exit to prevent mouse escape sequence garbage After the TUI exits, the terminal was left with mouse tracking enabled (\x1b[?1003l / \x1b[?1006l SGR mode), causing subsequent terminal input to print raw escape sequences like ^[<35;61;11M instead of being interpreted normally. Root cause: renderer.destroy() relies on native destroyRenderer() to send the mouse-disable sequences, but process.exit() in index.ts fires before those writes are flushed to stdout. Fixes: - exit.tsx: explicitly write mouse-disable + cursor-restore sequences to stdout before renderer.destroy(), and register a process 'exit' handler as a last-resort guarantee that fires synchronously on process exit. - prompt/index.tsx + session/index.tsx: add double-confirm for Ctrl+C exit (press twice within 3 s) matching the behaviour of claude code and similar TUI tools; inline hint replaces the toast for the first press. - win32.ts: tighten the ENABLE_PROCESSED_INPUT enforcement poll from 100 ms to 16 ms so the guard reacts faster after console-mode resets. Closes #13276 --- .../cli/cmd/tui/component/prompt/index.tsx | 56 ++++++++++++++++++- .../opencode/src/cli/cmd/tui/context/exit.tsx | 20 +++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 48 +++++++++++++++- packages/opencode/src/cli/cmd/tui/win32.ts | 2 +- 4 files changed, 121 insertions(+), 5 deletions(-) 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 747c61fd0bf9..d0f605eeff87 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -37,6 +37,8 @@ import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" +const EXIT_CONFIRM_MS = 3000 + export type PromptProps = { sessionID?: string workspaceID?: string @@ -95,8 +97,28 @@ export function Prompt(props: PromptProps) { const list = createMemo(() => props.placeholders?.normal ?? []) const shell = createMemo(() => props.placeholders?.shell ?? []) const [auto, setAuto] = createSignal() + const [exitConfirmArmed, setExitConfirmArmed] = createSignal(false) + const [exitPending, setExitPending] = createSignal(false) const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) + let exitConfirmTimeout: ReturnType | undefined + + const clearExitConfirm = () => { + setExitConfirmArmed(false) + if (exitConfirmTimeout) { + clearTimeout(exitConfirmTimeout) + exitConfirmTimeout = undefined + } + } + + const armExitConfirm = () => { + setExitConfirmArmed(true) + if (exitConfirmTimeout) clearTimeout(exitConfirmTimeout) + exitConfirmTimeout = setTimeout(() => { + setExitConfirmArmed(false) + exitConfirmTimeout = undefined + }, EXIT_CONFIRM_MS) + } function promptModelWarning() { toast.show({ @@ -429,6 +451,7 @@ export function Prompt(props: PromptProps) { } onCleanup(() => { + clearExitConfirm() props.ref?.(undefined) }) @@ -919,6 +942,13 @@ export function Prompt(props: PromptProps) { e.preventDefault() return } + if (exitPending()) { + e.preventDefault() + return + } + if (exitConfirmArmed() && !keybind.match("app_exit", e)) { + clearExitConfirm() + } // Check clipboard for images before terminal-handled paste runs. // This helps terminals that forward Ctrl+V to the app; Windows // Terminal 1.25+ usually handles Ctrl+V before this path. @@ -936,6 +966,7 @@ export function Prompt(props: PromptProps) { // If no image, let the default paste behavior continue } if (keybind.match("input_clear", e) && store.prompt.input !== "") { + clearExitConfirm() input.clear() input.extmarks.clear() setStore("prompt", { @@ -947,9 +978,15 @@ export function Prompt(props: PromptProps) { } if (keybind.match("app_exit", e)) { if (store.prompt.input === "") { - await exit() - // Don't preventDefault - let textarea potentially handle the event e.preventDefault() + if (exitConfirmArmed()) { + clearExitConfirm() + setExitPending(true) + await exit() + return + } + + armExitConfirm() return } } @@ -1144,7 +1181,20 @@ export function Prompt(props: PromptProps) { /> - }> + } + children={ + + {keybind.print("app_exit")} again to exit + + } + /> + } + > exit()) + // Last-resort: if process.exit() fires before renderer cleanup finishes, + // 'exit' event still runs synchronously and can write terminal reset sequences. + process.on("exit", () => { + process.stdout.write( + "\x1b[?1003l" + // disable any-event mouse tracking + "\x1b[?1006l" + // disable SGR mouse mode + "\x1b[?1000l" + // disable normal mouse tracking + "\x1b[?25h", // show cursor + ) + }) return exit }, }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 396d7563011f..5c287b38b48e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -7,6 +7,7 @@ import { For, Match, on, + onCleanup, onMount, Show, Switch, @@ -84,6 +85,8 @@ import { useTuiConfig } from "../../context/tui-config" import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "../../plugin" +const EXIT_CONFIRM_MS = 3000 + addDefaultParsers(parsers.parsers) const context = createContext<{ @@ -217,9 +220,32 @@ export function Session() { const keybind = useKeybind() const dialog = useDialog() const renderer = useRenderer() + const [exitConfirmArmed, setExitConfirmArmed] = createSignal(false) + const [exitPending, setExitPending] = createSignal(false) + let exitConfirmTimeout: ReturnType | undefined // Allow exit when in child session (prompt is hidden) const exit = useExit() + const clearExitConfirm = () => { + setExitConfirmArmed(false) + if (exitConfirmTimeout) { + clearTimeout(exitConfirmTimeout) + exitConfirmTimeout = undefined + } + } + + const armExitConfirm = () => { + setExitConfirmArmed(true) + if (exitConfirmTimeout) clearTimeout(exitConfirmTimeout) + exitConfirmTimeout = setTimeout(() => { + setExitConfirmArmed(false) + exitConfirmTimeout = undefined + }, EXIT_CONFIRM_MS) + } + + onCleanup(() => { + clearExitConfirm() + }) createEffect(() => { const title = Locale.truncate(session()?.title ?? "", 50) @@ -242,8 +268,28 @@ export function Session() { useKeyboard((evt) => { if (!session()?.parentID) return + if (exitPending()) { + evt.preventDefault() + return + } + if (exitConfirmArmed() && !keybind.match("app_exit", evt)) { + clearExitConfirm() + } if (keybind.match("app_exit", evt)) { - exit() + evt.preventDefault() + if (exitConfirmArmed()) { + clearExitConfirm() + setExitPending(true) + exit() + return + } + + armExitConfirm() + toast.show({ + message: `Press ${keybind.print("app_exit")} again to exit`, + variant: "info", + duration: EXIT_CONFIRM_MS, + }) } }) diff --git a/packages/opencode/src/cli/cmd/tui/win32.ts b/packages/opencode/src/cli/cmd/tui/win32.ts index 23e9f448574f..3ceb5b171de9 100644 --- a/packages/opencode/src/cli/cmd/tui/win32.ts +++ b/packages/opencode/src/cli/cmd/tui/win32.ts @@ -108,7 +108,7 @@ export function win32InstallCtrlCGuard() { // Ensure it's cleared immediately too (covers any earlier mode changes). later() - const interval = setInterval(enforce, 100) + const interval = setInterval(enforce, 16) interval.unref() let done = false From d03d7b97640f79a64b9e72e6849281b3839fd429 Mon Sep 17 00:00:00 2001 From: "mike.wang" Date: Wed, 8 Apr 2026 11:30:19 +0800 Subject: [PATCH 2/3] ci: raise windows e2e timeout to 45 minutes --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70a8477fb51f..8b5f85720bac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -133,7 +133,7 @@ jobs: env: CI: true PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml - timeout-minutes: 30 + timeout-minutes: ${{ matrix.settings.name == 'windows' && 45 || 30 }} - name: Upload Playwright artifacts if: always() From 87651d2849cf4ec36c71b1945b3ffe57fec38bd5 Mon Sep 17 00:00:00 2001 From: "mike.wang" Date: Wed, 8 Apr 2026 13:38:25 +0800 Subject: [PATCH 3/3] ci: restore e2e timeout to 30 minutes --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b5f85720bac..70a8477fb51f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -133,7 +133,7 @@ jobs: env: CI: true PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml - timeout-minutes: ${{ matrix.settings.name == 'windows' && 45 || 30 }} + timeout-minutes: 30 - name: Upload Playwright artifacts if: always()