Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -429,6 +451,7 @@ export function Prompt(props: PromptProps) {
}

onCleanup(() => {
clearExitConfirm()
props.ref?.(undefined)
})

Expand Down Expand Up @@ -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.
Expand All @@ -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", {
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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}
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/exit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
},
})
48 changes: 47 additions & 1 deletion packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
For,
Match,
on,
onCleanup,
onMount,
Show,
Switch,
Expand Down Expand Up @@ -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<{
Expand Down Expand Up @@ -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)
Expand All @@ -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,
})
}
})

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/tui/win32.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading