//! Terminal control for the TUI. //! //! Responsibilities: //! - enter / exit raw mode via termios (Unix only; Windows out of scope) //! - restore the original termios on normal exit AND on crash: //! * `defer term.deinit()` for the happy path //! * SIGINT / SIGTERM / SIGHUP handlers that restore and re-raise //! * a panic hook (`installPanicRestore`) the program's panic handler //! can call so a Zig panic still restores the terminal //! Restore is idempotent — calling it twice is safe and the second call //! is a no-op. //! - NEVER use the alternate screen buffer (no `?1049h` / `?47h`). We render //! inline in the primary buffer. //! - synchronized-output framing, cursor movement, line/screen clearing, //! cursor show/hide, absolute positioning. //! - query the window size (TIOCGWINSZ) and expose it; expose a SIGWINCH //! "resized" flag the render engine can poll. //! - best-effort capability detection (synchronized output, Kitty keyboard). //! //! All escape output goes straight to the tty fd via `std.c.write` (libc is //! linked). The signal/panic restore path also uses `std.c.write` / //! `std.c.tcsetattr`, which are async-signal-safe. //! //! Threading / global-state note: termios restore from a signal handler needs //! the saved state at a fixed address. We keep a single file-scope //! `active_restore` pointer to the live `Terminal`'s saved termios + fd. This //! means ONE `Terminal` is "active" for signal restore at a time (the TUI only //! ever creates one). `init` registers it; `deinit` clears it. const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; /// Raw escape sequences. Kept as named constants so the engine and tests can /// reference them and so the "no alt screen" rule is auditable in one place. pub const seq = struct { /// Begin / end synchronized output (DEC private mode 2026). Wrapping a /// frame in these tells the terminal to buffer and present atomically, /// avoiding tearing. pub const sync_begin = "\x1b[?2026h"; pub const sync_end = "\x1b[?2026l"; /// Full-redraw clear: clear screen, home cursor, AND clear scrollback /// (`3J`). Used on first paint / width change / height change. pub const full_clear = "\x1b[2J\x1b[H\x1b[3J"; /// Carriage return (column 0, same row). pub const carriage_return = "\r"; /// Cursor-forward (CUF). The full escape `\x1b[C` is built at use /// sites via `std.fmt` because `n` varies; documented here so the /// sequence is centralized conceptually. `\x1b[C` moves the cursor /// RIGHT by `n` columns WITHOUT writing glyphs, so it never overwrites /// existing content (unlike printing spaces). Used to place the hardware /// cursor at the virtual-cursor column for IME anchoring (see /// `Engine.positionHardwareCursor`). /// Clear from cursor to end of line. pub const clear_line = "\x1b[K"; /// Clear from cursor to end of screen. pub const clear_to_end = "\x1b[J"; pub const hide_cursor = "\x1b[?25l"; pub const show_cursor = "\x1b[?25h"; }; pub const Size = struct { rows: u16, cols: u16, }; pub const Error = error{ NotATty, TermiosGetFailed, TermiosSetFailed, }; // ---- Global state for signal/panic restore ------------------------------- /// Minimal restore record: the fd whose termios we need to restore plus the /// saved original. Pointed to by `active_restore` while a Terminal is live. const Restore = struct { fd: posix.fd_t, original: posix.termios, /// Set once restore has run, so it's idempotent across signal + deinit. done: bool = false, }; /// The single active restore record (see module note). `null` when no Terminal /// is in raw mode. var active_restore: ?*Restore = null; /// Async-signal-safe restore: reset termios and show the cursor. Safe to call /// multiple times. Uses only `tcsetattr` + `write`, both signal-safe. fn doRestore(r: *Restore) void { if (r.done) return; r.done = true; // Best-effort; ignore errors (we may be unwinding a crash). posix.tcsetattr(r.fd, .FLUSH, r.original) catch {}; _ = std.c.write(r.fd, seq.show_cursor.ptr, seq.show_cursor.len); } fn signalRestoreHandler(sig: posix.SIG) callconv(.c) void { if (active_restore) |r| doRestore(r); // Re-raise with the default disposition so the process dies as expected. posix.sigaction(sig, &.{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0, }, null); _ = std.c.raise(sig); } /// SIGWINCH flag. Set by the handler, polled (and cleared) by the engine via /// `Terminal.takeResized`. var winch_flag: std.atomic.Value(bool) = .init(false); fn winchHandler(sig: posix.SIG) callconv(.c) void { _ = sig; winch_flag.store(true, .seq_cst); } // ---- Capability detection ------------------------------------------------- pub const Capabilities = struct { /// Terminal is believed to support synchronized output (mode 2026). synchronized_output: bool, /// Whether the Kitty keyboard protocol is in effect. Initially an env-sniffed /// best guess (see `detectCapabilities`); the app then runs a startup /// query/response handshake (`negotiate_query`) and overwrites this with the /// confirmed result — true if the terminal acknowledged Kitty flags, false /// if it fell back to modifyOtherKeys. Decode logic that depends on the live /// protocol reads the module-global `tui_input.kittyActive()`, which the app /// keeps in sync. kitty_keyboard: bool, }; /// Best-effort capability detection by environment sniffing. /// /// We do NOT block on a terminal query response here (that risks hanging on /// terminals that don't reply). Assumptions: /// - Synchronized output is widely supported by modern emulators; we treat /// it as available unless `TERM` looks like a dumb/legacy terminal. The /// sequences are no-ops on terminals that don't understand them, so a /// false positive is harmless. /// - Kitty keyboard support is inferred from `TERM`/`TERM_PROGRAM` env hints /// (kitty, foot, ghostty, wezterm, recent alacritty). A false negative /// just means we fall back to legacy decoding; a false positive is safe /// because the enable sequence is ignored by terminals that don't grok it /// and our decoder degrades gracefully on unrecognized input. pub fn detectCapabilities(env: std.process.Environ.Map) Capabilities { const term = env.get("TERM") orelse ""; const term_program = env.get("TERM_PROGRAM") orelse ""; const dumb = std.mem.eql(u8, term, "") or std.mem.eql(u8, term, "dumb"); const kitty = containsAny(term, &.{ "kitty", "foot", "ghostty", "wezterm" }) or containsAny(term_program, &.{ "kitty", "foot", "ghostty", "WezTerm", "ghostty" }) or env.get("KITTY_WINDOW_ID") != null; return .{ .synchronized_output = !dumb, .kitty_keyboard = kitty, }; } fn containsAny(haystack: []const u8, needles: []const []const u8) bool { for (needles) |n| { if (std.ascii.indexOfIgnoreCase(haystack, n) != null) return true; } return false; } // ---- The Terminal -------------------------------------------------------- pub const Terminal = struct { fd: posix.fd_t, restore: Restore, /// Whether raw mode is currently engaged. raw_mode: bool = false, size: Size, caps: Capabilities, /// Saved prior SIGWINCH disposition is not restored (handler is process- /// global and harmless); we only track that we installed ours. installed_signals: bool = false, /// Open the controlling terminal on `fd` (typically stdin) and capture its /// current termios. Does NOT enter raw mode yet — call `enableRawMode`. pub fn init(fd: posix.fd_t, env: std.process.Environ.Map) Error!Terminal { if (std.c.isatty(fd) == 0) return Error.NotATty; const original = posix.tcgetattr(fd) catch return Error.TermiosGetFailed; const size = querySize(fd) orelse Size{ .rows = 24, .cols = 80 }; return .{ .fd = fd, .restore = .{ .fd = fd, .original = original }, .size = size, .caps = detectCapabilities(env), }; } /// Enter raw mode and install signal handlers + the global restore record. /// Hides nothing on its own; the engine controls the cursor. pub fn enableRawMode(self: *Terminal) Error!void { if (self.raw_mode) return; var raw = self.restore.original; // Input flags: no break->int, no CR->NL, no parity check, no strip, // no flow control. raw.iflag.BRKINT = false; raw.iflag.ICRNL = false; raw.iflag.INPCK = false; raw.iflag.ISTRIP = false; raw.iflag.IXON = false; // Output: keep post-processing OFF so our explicit \r\n is exact. raw.oflag.OPOST = false; // Control: 8-bit chars. raw.cflag.CSIZE = .CS8; // Local flags: no echo, non-canonical, no signals from keys (we read // Ctrl+C ourselves), no extended processing. raw.lflag.ECHO = false; raw.lflag.ICANON = false; raw.lflag.ISIG = false; raw.lflag.IEXTEN = false; // Non-blocking-ish read: return as soon as >=1 byte is available. raw.cc[@intFromEnum(posix.V.MIN)] = 1; raw.cc[@intFromEnum(posix.V.TIME)] = 0; posix.tcsetattr(self.fd, .FLUSH, raw) catch return Error.TermiosSetFailed; self.raw_mode = true; self.restore.done = false; active_restore = &self.restore; self.installSignalHandlers(); } fn installSignalHandlers(self: *Terminal) void { const restore_act = posix.Sigaction{ .handler = .{ .handler = signalRestoreHandler }, .mask = posix.sigemptyset(), .flags = 0, }; posix.sigaction(posix.SIG.INT, &restore_act, null); posix.sigaction(posix.SIG.TERM, &restore_act, null); posix.sigaction(posix.SIG.HUP, &restore_act, null); const winch_act = posix.Sigaction{ .handler = .{ .handler = winchHandler }, .mask = posix.sigemptyset(), .flags = 0, }; posix.sigaction(posix.SIG.WINCH, &winch_act, null); self.installed_signals = true; } /// Restore the terminal: leave raw mode, show the cursor. Idempotent. /// Safe to call from `defer`. Does not touch the alternate screen (we /// never entered it). pub fn deinit(self: *Terminal) void { doRestore(&self.restore); self.raw_mode = false; if (active_restore == &self.restore) active_restore = null; } /// Temporarily return the tty to its ORIGINAL (cooked) termios so a child /// process (e.g. `$EDITOR`) can drive the terminal normally, and show the /// cursor. Pairs with `resumeRawMode`. Unlike `deinit`, this does NOT clear /// the global restore record or mark the restore done — the signal/panic /// restore path stays armed with the original termios (restoring to cooked /// is exactly what we'd want on a crash mid-editor), and we re-enter raw /// mode afterward. /// /// No-op if not currently in raw mode. pub fn suspendRawMode(self: *Terminal) void { if (!self.raw_mode) return; posix.tcsetattr(self.fd, .FLUSH, self.restore.original) catch {}; self.raw_mode = false; self.writeAll(seq.show_cursor); } /// Re-enter raw mode after `suspendRawMode` and hide the cursor again. The /// global restore record and signal handlers are already installed (they /// were never torn down), so this just reapplies the raw termios. pub fn resumeRawMode(self: *Terminal) Error!void { if (self.raw_mode) return; try self.enableRawMode(); self.writeAll(seq.hide_cursor); } // -- output helpers ----------------------------------------------------- /// Raw write of `bytes` to the tty. Returns the count written (short /// writes are possible on a tty but rare for small control sequences; /// callers writing large frames should loop or use a buffered writer). pub fn writeAll(self: *Terminal, bytes: []const u8) void { var off: usize = 0; while (off < bytes.len) { const n = std.c.write(self.fd, bytes.ptr + off, bytes.len - off); if (n <= 0) break; off += @intCast(n); } } pub fn beginSync(self: *Terminal) void { if (self.caps.synchronized_output) self.writeAll(seq.sync_begin); } pub fn endSync(self: *Terminal) void { if (self.caps.synchronized_output) self.writeAll(seq.sync_end); } pub fn hideCursor(self: *Terminal) void { self.writeAll(seq.hide_cursor); } pub fn showCursor(self: *Terminal) void { self.writeAll(seq.show_cursor); } pub fn carriageReturn(self: *Terminal) void { self.writeAll(seq.carriage_return); } pub fn clearLine(self: *Terminal) void { self.writeAll(seq.clear_line); } pub fn clearToEndOfScreen(self: *Terminal) void { self.writeAll(seq.clear_to_end); } /// Full redraw clear (clears scrollback too). Use only on first paint / /// width change / height change. pub fn fullClear(self: *Terminal) void { self.writeAll(seq.full_clear); } /// Move the cursor up `n` rows (no-op when `n == 0`). Writes into `buf`. pub fn cursorUp(self: *Terminal, n: usize) void { if (n == 0) return; var buf: [16]u8 = undefined; const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return; self.writeAll(s); } /// Move the cursor down `n` rows (no-op when `n == 0`). pub fn cursorDown(self: *Terminal, n: usize) void { if (n == 0) return; var buf: [16]u8 = undefined; const s = std.fmt.bufPrint(&buf, "\x1b[{d}B", .{n}) catch return; self.writeAll(s); } /// Move to absolute (row, col), 1-based (CUP). pub fn moveTo(self: *Terminal, row: usize, col: usize) void { var buf: [32]u8 = undefined; const s = std.fmt.bufPrint(&buf, "\x1b[{d};{d}H", .{ row, col }) catch return; self.writeAll(s); } // -- size / resize ------------------------------------------------------ /// Re-query the window size from the kernel and update `self.size`. /// Returns the new size. Falls back to the previous size on failure. pub fn refreshSize(self: *Terminal) Size { if (querySize(self.fd)) |s| self.size = s; return self.size; } /// Poll-and-clear the SIGWINCH flag. Returns true exactly once per resize /// burst the engine hasn't yet observed. pub fn takeResized(self: *Terminal) bool { _ = self; return winch_flag.swap(false, .seq_cst); } /// Install a panic-time restore. Call this from the program's panic /// handler (or once at startup if your panic handler is the default and /// you want best effort). It restores the currently-active terminal. pub fn installPanicRestore() void { if (active_restore) |r| doRestore(r); } }; /// Query the terminal size via TIOCGWINSZ. Returns null on failure. fn querySize(fd: posix.fd_t) ?Size { var ws: posix.winsize = undefined; const rc = std.c.ioctl(fd, std.c.T.IOCGWINSZ, @intFromPtr(&ws)); if (rc != 0) return null; if (ws.row == 0 or ws.col == 0) return null; return .{ .rows = ws.row, .cols = ws.col }; } // ---- Tests ---------------------------------------------------------------- test "no alt screen sequences anywhere in seq" { // Guardrail: the alternate-screen private modes must never appear. inline for (.{ seq.sync_begin, seq.sync_end, seq.full_clear, seq.clear_line, seq.clear_to_end, seq.hide_cursor, seq.show_cursor, }) |s| { try std.testing.expect(std.mem.indexOf(u8, s, "?1049") == null); try std.testing.expect(std.mem.indexOf(u8, s, "?47") == null); } } test "full clear includes scrollback (3J)" { try std.testing.expect(std.mem.indexOf(u8, seq.full_clear, "\x1b[3J") != null); } test "sync framing values" { try std.testing.expectEqualStrings("\x1b[?2026h", seq.sync_begin); try std.testing.expectEqualStrings("\x1b[?2026l", seq.sync_end); } test "capability detection: dumb terminal" { var env = std.process.Environ.Map.init(std.testing.allocator); defer env.deinit(); try env.put("TERM", "dumb"); const caps = detectCapabilities(env); try std.testing.expect(!caps.synchronized_output); try std.testing.expect(!caps.kitty_keyboard); } test "capability detection: kitty" { var env = std.process.Environ.Map.init(std.testing.allocator); defer env.deinit(); try env.put("TERM", "xterm-kitty"); const caps = detectCapabilities(env); try std.testing.expect(caps.synchronized_output); try std.testing.expect(caps.kitty_keyboard); } test "resize flag round-trips" { winch_flag.store(false, .seq_cst); var t: Terminal = .{ .fd = 0, .restore = .{ .fd = 0, .original = undefined }, .size = .{ .rows = 24, .cols = 80 }, .caps = .{ .synchronized_output = true, .kitty_keyboard = false }, }; try std.testing.expect(!t.takeResized()); winch_flag.store(true, .seq_cst); try std.testing.expect(t.takeResized()); try std.testing.expect(!t.takeResized()); }