How Ghostty Manages Terminal Modes: DEC Private Modes, DECCKM, and DECSCUSR Explained

Ghostty implements terminal modes as a compile-time generated packed bitfield in ModeState (8 bytes), with DECCKM mapped to the cursor_keys enum and DECSCUSR handled as a CSI q command that sets an internal CursorStyle enum.

Ghostty is a fast, feature-rich terminal emulator written in Zig that achieves VT220 compatibility through a sophisticated type-safe state machine. Understanding how Ghostty manages DEC private modes—including DECCKM for application cursor keys and DECSCUSR for cursor styling—reveals an architecture that prioritizes both performance and spec compliance. All mode state lives in a compact 8-byte structure generated at compile time from a central declaration table.

The Mode State Machine Architecture

Ghostty treats terminal modes as a strongly typed state machine centered in src/terminal/modes.zig. Rather than using hashtables or sparse arrays, the emulator generates a dense, packed representation that fits each mode into a single bit.

Centralized Mode Definitions

All supported DEC and ANSI modes are declared in a single table named entries in src/terminal/modes.zig. Each ModeEntry specifies the mode name, its numeric identifier, default value, and whether it uses ANSI or DEC namespace.

// Excerpt from src/terminal/modes.zig
const entries: []const ModeEntry = &.{
    // DEC private modes
    .{ .name = "cursor_keys", .value = 1 },  // DECCKM
    .{ .name = "cursor_blinking", .value = 12 },
    // ... additional entries
};

Packed Bitfield Representation

From this table, Ghostty generates three compile-time constructs:

  1. Mode enum – Maps numeric identifiers (e.g., 1 for DECCKM) to typed enum members (e.g., .cursor_keys)
  2. ModePacked – A packed struct where each mode occupies one bit (@sizeOf(ModePacked) == 8)
  3. ModeState – A wrapper holding current values, saved values, and defaults as ModePacked instances
// From src/terminal/modes.zig
pub const ModeState = struct {
    values: ModePacked = .{},
    saved: ModePacked = .{},
    default: ModePacked = .{},

    pub fn set(self: *ModeState, mode: Mode, value: bool) void { 
        // Implementation sets specific bit
    }
    
    pub fn get(self: *const ModeState, mode: Mode) bool { 
        // Implementation reads specific bit
    }
    
    pub fn save(self: *ModeState, mode: Mode) void { 
        self.saved = self.values; 
    }
    
    pub fn restore(self: *ModeState, mode: Mode) bool { 
        self.values = self.saved; 
        return true;
    }
};

Accessing any Ghostty terminal mode requires only a single memory read or write to this 8-byte structure, eliminating the overhead of hash lookups or branching dispatch tables.

DECCKM (Application Cursor Keys) Implementation

DECCKM (DEC Private Mode 1) switches the numeric keypad and arrow keys between normal and application mode. In Ghostty, this maps directly to the cursor_keys mode entry.

CSI Parsing in stream.zig

When the parser encounters CSI ?1h (set) or CSI ?1l (reset), the dispatch logic in src/terminal/stream.zig converts the numeric parameter into the typed Mode enum and forwards it to the handler:

// From src/terminal/stream.zig
case 'h' => { // Set Mode
    const mode = modes.modeFromInt(param, ansi = false).?;
    self.handler.vt(.set_mode, .{ .mode = mode, .enabled = true });
}

The StreamHandler.setMode implementation then calls self.terminal.modes.set(mode, enabled), updating the packed bitfield in ModeState.

Runtime Usage in Surface.zig

Ghostty consults the cursor_keys state when generating arrow key escape sequences. In src/Surface.zig (and related input handling), the application checks t.modes.get(.cursor_keys) to determine whether to send application sequences (\x1bOD) or normal sequences (\x1b[D):

// Conceptual usage in input handling
const left_arrow = if (t.modes.get(.cursor_keys)) "\x1bOD" else "\x1b[D";

DECSCUSR (Select Cursor Style) Handling

Unlike DECCKM, DECSCUSR is not a mode but a CSI command that directly manipulates cursor appearance. Ghostty implements this as a q subcommand that maps numeric arguments to an internal ansi.CursorStyle enum.

CSI q Parsing

The parser in src/terminal/stream.zig handles CSI ? q (DECSCUSR) by matching the intermediate character and mapping the parameter to cursor styles:

// From src/terminal/stream.zig (lines 88-106)
'q' => switch (input.intermediates[0]) {
    ' ' => {
        const style = switch (input.params.len) {
            0 => .default,
            1 => switch (input.params[0]) {
                0 => .default,
                1 => .blinking_block,
                2 => .steady_block,
                3 => .blinking_underline,
                4 => .steady_underline,
                5 => .blinking_bar,
                6 => .steady_bar,
                else => { log.warn("unknown cursor style"); return; },
            },
            else => { log.warn("invalid DECSCUSR params"); return; },
        };
        self.handler.vt(.cursor_style, style);
    },
    // ...
},

Cursor Style and Mode Interaction

DECSCUSR interacts with DEC private mode 12 (cursor blinking), which controls whether the cursor blinks globally. When reporting the current state via DECRQSS, Ghostty combines the active cursor style with the cursor_blinking mode bit. In src/termio/stream_handler.zig, the response formatter reads both the style and the mode state:

// From src/termio/stream_handler.zig
const blink = self.terminal.modes.get(.cursor_blinking);
const style: u8 = switch (self.terminal.screens.active.cursor.cursor_style) {
    .block => if (blink) 1 else 2,
    .underline => if (blink) 3 else 4,
    .bar => if (blink) 5 else 6,
    .block_hollow => if (blink) 1 else 2,
};
try writer.print("{d} q", .{style});

If the user has configured a default blink preference, setMode in stream_handler.zig skips DEC mode 12 to preserve the DECSCUSR behavior, ensuring that explicit cursor style commands take precedence.

Mode Reporting and DECRPM

Ghostty generates DECRPM (DEC Report Mode) responses using the Report.encode method in src/terminal/modes.zig. This encodes the mode's numeric tag and current state (set, reset, permanently set, etc.) according to VT specifications:

// From src/terminal/modes.zig
try writer.print("\x1B[{s}{};{}$y", .{
    if (self.tag.ansi) "" else "?",
    self.tag.value,
    @intFromEnum(self.state),
});

The StreamHandler.sendModeReport method wraps this functionality to communicate mode state back to host applications.

Practical Code Examples

The following Zig snippets demonstrate how to interact with Ghostty's mode system programmatically:

const std = @import("std");
const terminal = @import("terminal");

// Enable DECCKM (application cursor keys)
pub fn enableCursorKeys(t: *terminal.Terminal) void {
    t.modes.set(.cursor_keys, true);
}

// Query DECCKM state
pub fn isCursorKeysEnabled(t: *terminal.Terminal) bool {
    return t.modes.get(.cursor_keys);
}

// Set cursor style to blinking block (DECSCUSR 1)
pub fn setBlinkingBlock(t: *terminal.Terminal) void {
    t.handler.vt(.cursor_style, .blinking_block);
}

// Reset cursor style to terminal default (DECSCUSR 0)
pub fn resetCursorStyle(t: *terminal.Terminal) void {
    t.handler.vt(.cursor_style, .default);
}

Summary

  • Ghostty stores all DEC/ANSI private modes in a compile-time generated ModeState struct using an 8-byte packed bitfield for O(1) access.
  • DECCKM maps to cursor_keys (mode value 1) and is toggled via CSI ?1h/l, affecting arrow key escape sequences generated in src/Surface.zig.
  • DECSCUSR is a CSI q command, not a mode, that sets an internal CursorStyle enum; it interacts with mode 12 (cursor_blinking) when reporting state via DECRQSS.
  • Mode reporting uses Report.encode in src/terminal/modes.zig to format DECRPM responses with proper ANSI/DEC prefixes and state codes.

Frequently Asked Questions

What is the difference between DECCKM and DECSCUSR in Ghostty?

DECCKM is a true DEC private mode (mode 1) stored in the ModeState bitfield that changes the behavior of arrow keys. DECSCUSR is a CSI command (CSI q) that directly sets cursor appearance without changing mode state, though Ghostty tracks the selected style separately from the cursor_blinking mode (mode 12).

How much memory does Ghostty use to store terminal modes?

Ghostty uses exactly 8 bytes (@sizeOf(ModePacked)) to store the current, saved, and default values for all supported modes combined. This packed representation in src/terminal/modes.zig ensures cache-friendly access patterns and minimal memory overhead.

Can Ghostty save and restore mode states?

Yes. The ModeState struct in src/terminal/modes.zig provides save() and restore() methods that copy the current ModePacked bitfield to and from a backup field. This implements the DEC save/restore cursor and mode semantics used by applications like tmux and vim.

How does Ghostty handle the cursor blinking mode (DEC mode 12)?

Ghostty treats mode 12 (cursor_blinking) as a global toggle that can be overridden by user configuration. When processing CSI ?12h/l, the setMode handler in src/termio/stream_handler.zig checks user preferences; if the user has disabled blinking globally, the mode change is ignored to preserve consistent cursor behavior across applications.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →