pull/21429/merge
王亮 2026-04-08 13:38:47 +08:00 committed by GitHub
commit 8c78099cf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 121 additions and 5 deletions

View File

@ -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}

View File

@ -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
},
})

View File

@ -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,
})
}
})

View File

@ -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