diff --git a/.github/workflows/nix-desktop.yml b/.github/workflows/nix-desktop.yml index b7919c062cc..b2533ef9f6c 100644 --- a/.github/workflows/nix-desktop.yml +++ b/.github/workflows/nix-desktop.yml @@ -35,6 +35,13 @@ jobs: - name: Setup Nix uses: DeterminateSystems/nix-installer-action@v21 + - name: Test macOS terminal restoration + if: runner.os == 'macOS' + run: | + set -euo pipefail + echo "Testing macOS terminal restoration..." + node ./script/macos-terminal-test.js + - name: Build desktop via flake run: | set -euo pipefail diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3a9a9b4ad47..23fc40cf9e2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -154,6 +154,13 @@ jobs: workspaces: packages/desktop/src-tauri shared-key: ${{ matrix.settings.target }} + - name: Test macOS terminal restoration + if: runner.os == 'macOS' + run: | + set -euo pipefail + echo "Testing macOS terminal restoration..." + node ./script/macos-terminal-test.js + - name: Prepare run: | cd packages/desktop diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index aa62c6c58ef..205e6cb927d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -115,11 +115,58 @@ export function tui(input: { resolve() } + // macOS-specific signal handling + const setupSignalHandlers = (renderer: any) => { + // Handle Ctrl+C gracefully + const handleSigint = () => { + renderer.setTerminalTitle("") + renderer.destroy() + process.exit(0) + } + + // Handle terminal closure + const handleSighup = () => { + renderer.setTerminalTitle("") + renderer.destroy() + process.exit(0) + } + + // Handle termination signal + const handleSigterm = () => { + renderer.setTerminalTitle("") + renderer.destroy() + process.exit(0) + } + + process.on("SIGINT", handleSigint) + process.on("SIGHUP", handleSighup) + process.on("SIGTERM", handleSigterm) + + return () => { + process.off("SIGINT", handleSigint) + process.off("SIGHUP", handleSighup) + process.off("SIGTERM", handleSigterm) + } + } + render( () => { + const renderer = useRenderer() + const cleanupSignalHandlers = setupSignalHandlers(renderer) + return ( } + fallback={(error, reset) => ( + { + cleanupSignalHandlers() + await onExit() + }} + mode={mode} + /> + )} > @@ -514,9 +561,19 @@ function App() { keybind: "terminal_suspend", category: "System", onSelect: () => { - process.once("SIGCONT", () => { + // Enhanced macOS suspend handling + const resumeHandler = () => { + // Restore terminal state on macOS + process.stdout.write("\x1b[?47h") // Enable alternate screen + process.stdout.write("\x1b[?1049h") // Save cursor and use alternate screen buffer renderer.resume() - }) + } + + process.once("SIGCONT", resumeHandler) + + // Clear alternate screen before suspend on macOS + process.stdout.write("\x1b[?1049l") // Use normal screen buffer + process.stdout.write("\x1b[?47l") // Disable alternate screen renderer.suspend() // pid=0 means send the signal to all processes in the process group diff --git a/packages/opencode/src/cli/cmd/tui/context/exit.tsx b/packages/opencode/src/cli/cmd/tui/context/exit.tsx index 414cb1a41d0..1875cdf410f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/exit.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/exit.tsx @@ -7,6 +7,19 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({ init: (input: { onExit?: () => Promise }) => { const renderer = useRenderer() return async (reason?: any) => { + // macOS terminal restoration + try { + // Reset terminal to known good state + process.stdout.write("\x1bc") // Reset terminal + process.stdout.write("\x1b[?1049l") // Restore normal screen buffer + process.stdout.write("\x1b[?47l") // Disable alternate screen + process.stdout.write("\x1b[0m") // Reset attributes + process.stdout.write("\x1b[2J") // Clear screen + process.stdout.write("\x1b[H") // Move cursor to top-left + } catch (e) { + // Ignore write errors during shutdown + } + // Reset window title before destroying renderer renderer.setTerminalTitle("") renderer.destroy() diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts index 2b81068b3f9..ce3ce880a4b 100644 --- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -1,5 +1,15 @@ import { RGBA } from "@opentui/core" +export function isMacOSTerminal(): boolean { + return ( + process.platform === "darwin" && + (process.env.TERM_PROGRAM === "Apple_Terminal" || + process.env.TERM_PROGRAM === "iTerm.app" || + (process.env.TERM?.includes("xterm") ?? false) || + (process.env.TERM?.includes("screen") ?? false)) + ) +} + export namespace Terminal { export type Colors = Awaited> /** diff --git a/script/macos-terminal-test.js b/script/macos-terminal-test.js new file mode 100755 index 00000000000..d5d23c3faf3 --- /dev/null +++ b/script/macos-terminal-test.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node + +// Simple test for terminal restoration changes +// This script simulates the signal handling we added + +console.log("Testing macOS terminal restoration...") + +// Test isMacOSTerminal function +const isMacOSTerminal = () => { + return ( + process.platform === "darwin" && + (process.env.TERM_PROGRAM === "Apple_Terminal" || + process.env.TERM_PROGRAM === "iTerm.app" || + process.env.TERM?.includes("xterm") || + process.env.TERM?.includes("screen")) + ) +} + +console.log("Platform:", process.platform) +console.log("Terminal:", process.env.TERM_PROGRAM || process.env.TERM) +console.log("Is macOS terminal:", isMacOSTerminal()) + +// Test escape sequences +const testTerminalReset = () => { + try { + process.stdout.write("\x1bc") // Reset terminal + process.stdout.write("\x1b[?1049l") // Restore normal screen buffer + process.stdout.write("\x1b[?47l") // Disable alternate screen + process.stdout.write("\x1b[0m") // Reset attributes + process.stdout.write("\x1b[2J") // Clear screen + process.stdout.write("\x1b[H") // Move cursor to top-left + console.log("✓ Terminal reset sequences executed successfully") + } catch (e) { + console.log("✗ Error executing terminal reset:", e.message) + } +} + +console.log("Testing terminal reset sequences...") +testTerminalReset() + +// Test signal handling setup +let signalHandlersCleared = false + +const setupSignalHandlers = (renderer) => { + const handleSigint = () => { + console.log("SIGINT received - would clean up and exit") + renderer.setTerminalTitle("") + renderer.destroy() + process.exit(0) + } + + const handleSighup = () => { + console.log("SIGHUP received - would clean up and exit") + renderer.setTerminalTitle("") + renderer.destroy() + process.exit(0) + } + + const handleSigterm = () => { + console.log("SIGTERM received - would clean up and exit") + renderer.setTerminalTitle("") + renderer.destroy() + process.exit(0) + } + + process.on("SIGINT", handleSigint) + process.on("SIGHUP", handleSighup) + process.on("SIGTERM", handleSigterm) + + return () => { + process.off("SIGINT", handleSigint) + process.off("SIGHUP", handleSighup) + process.off("SIGTERM", handleSigterm) + signalHandlersCleared = true + console.log("✓ Signal handlers cleared") + } +} + +// Mock renderer for testing +const mockRenderer = { + setTerminalTitle: (title) => console.log(`Setting terminal title: "${title}"`), + destroy: () => console.log("Renderer destroy called"), +} + +console.log("Testing signal handler setup...") +const cleanup = setupSignalHandlers(mockRenderer) + +console.log("Sending SIGINT signal...") +process.emit("SIGINT") + +setTimeout(() => { + if (signalHandlersCleared) { + console.log("✓ All tests passed!") + process.exit(0) + } else { + console.log("✗ Signal handlers not cleared properly") + process.exit(1) + } +}, 100)