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 747c61fd0b..d0f605eeff 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 396d756301..5c287b38b4 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 23e9f44857..3ceb5b171d 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