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 #13276pull/21429/head
parent
ae614d919f
commit
fcb658a54e
|
|
@ -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<AutocompleteRef>()
|
||||
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<typeof setTimeout> | 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) {
|
|||
/>
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between">
|
||||
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
|
||||
<Show
|
||||
when={status().type !== "idle"}
|
||||
fallback={
|
||||
<Show
|
||||
when={exitConfirmArmed()}
|
||||
fallback={props.hint ?? <text />}
|
||||
children={
|
||||
<text fg={theme.warning}>
|
||||
{keybind.print("app_exit")} <span style={{ fg: theme.textMuted }}>again to exit</span>
|
||||
</text>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,16 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|||
await input.onBeforeExit?.()
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
// Disable mouse tracking synchronously before destroy.
|
||||
// renderer.destroy() may defer native cleanup when called during a
|
||||
// render frame, so we also register a process 'exit' handler as a
|
||||
// last-resort safeguard that runs right before the process terminates.
|
||||
const MOUSE_RESET =
|
||||
"\x1b[?1003l" + // disable any-event mouse tracking
|
||||
"\x1b[?1006l" + // disable SGR mouse mode
|
||||
"\x1b[?1000l" + // disable normal mouse tracking
|
||||
"\x1b[?25h" // show cursor
|
||||
process.stdout.write(MOUSE_RESET)
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
if (reason) {
|
||||
|
|
@ -55,6 +65,16 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
|||
},
|
||||
)
|
||||
process.on("SIGHUP", () => 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
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout> | 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,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue