a73x

docs/superpowers/plans/2026-04-19-cursor-blink-implementation.md

Ref:   Size: 37.3 KiB

# Cursor Blink Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a blinking cursor to waystty with full `DECSCUSR` shape support (block / underline / bar), `CSI ? 12 h/l` blink-mode, focused-only blink, and 500 ms phase. See spec `docs/superpowers/specs/2026-04-18-cursor-blink-design.md`.

**Architecture:** Four pure helpers — `cursorInstance` in `src/renderer.zig`, and `tickBlinkPhase` / `reconfigureBlink` / `shouldDrawCursor` in `src/main.zig` — wired into the existing deadline-driven main loop alongside the key-repeat deadline. No new module, no new thread, no new protocol. Ghostty's `render_state.cursor.visual_style` (shape), `.visible`, `.blinking`, `.viewport` plus `keyboard.has_focus` drive everything.

**Tech Stack:** Zig 0.15, vulkan-zig, zig-wayland, vendored `ghostty_vt` (as `ghostty`) for VT parsing.

---

## Reference facts (grep once, reuse)

- Cursor instance built inline at `src/main.zig:553-577` — this is what Task 4 replaces.
- Key-repeat deadline helpers at `src/main.zig:786-797` — shape to copy.
- Existing `computePollTimeoutMs` test at `src/main.zig:1633-1637` — extend.
- `planRowRefresh` + `CursorRefreshContext` at `src/main.zig:1527-1561` — touched only at the call site (L505-518), not modified internally.
- `renderer.Instance` struct at `src/renderer.zig:241-248` — cursorInstance returns this.
- `font.Atlas.cursorUV()` at `src/font.zig:229-240` returns `GlyphUV { u0, v0, u1, v1, width, height, bearing_x, bearing_y, advance_x }`.
- Ghostty cursor shape enum: `visual_style: cursor.Style` where `Style = enum { bar, block, underline, block_hollow }`. Confirmed at `~/.cache/zig/p/ghostty-1.3.2-dev-*/src/terminal/cursor.zig`.
- Ghostty render cursor fields confirmed: `visible: bool`, `blinking: bool`, `viewport: ?Viewport`, `visual_style: cursor.Style`.
- `keyboard.has_focus: bool` at `src/wayland.zig:219`.
- The test runner is `zig build test`. Unit tests live inline alongside the code they test.

---

## Task 1: Extract `cursorInstance` pure helper in `src/renderer.zig`

**Files:**
- Modify: `src/renderer.zig:241-248` (context) — add `cursorInstance` free function and its tests nearby
- Modify: `src/main.zig:553-577` — call the new helper (minimal churn; full replace happens in Task 4)

**What:** A pure function that returns a `renderer.Instance` for a given cursor shape. Block stays the current behavior; underline/bar add the two new shapes. Fallback-to-block handles `.block_hollow` or any future variant we don't explicitly support.

- [ ] **Step 1: Add the `CursorShape` alias and the failing test at the bottom of `src/renderer.zig`.**

```zig
// --- cursor instance (pure, unit-tested) ---

pub const CursorShape = enum { block, underline, bar };

pub fn cursorInstance(
    shape: CursorShape,
    cell_w: u32,
    cell_h: u32,
    buffer_scale: u32,
    uv: GlyphUV,
) Instance {
    @compileError("cursorInstance: not yet implemented");
}

test "cursorInstance: block fills the whole cell at scale 1" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.block, 10, 20, 1, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 10.0, 20.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
}

test "cursorInstance: underline is a 2px bar at the cell bottom (scale 1)" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.underline, 10, 20, 1, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 10.0, 2.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 18.0 }, &inst.glyph_bearing);
}

test "cursorInstance: bar is a 2px column at cell left (scale 1)" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.bar, 10, 20, 1, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 2.0, 20.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
}

test "cursorInstance: underline line width scales with buffer_scale" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.underline, 20, 40, 2, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 20.0, 4.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 36.0 }, &inst.glyph_bearing);
}

test "cursorInstance: bar line width scales with buffer_scale" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.bar, 20, 40, 2, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 4.0, 40.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
}

test "cursorInstance: uv_rect comes from the provided UV" {
    const uv: GlyphUV = .{ .u0 = 0.1, .v0 = 0.2, .u1 = 0.3, .v1 = 0.4, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.block, 10, 20, 1, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.1, 0.2, 0.3, 0.4 }, &inst.uv_rect);
}
```

At the top of `src/renderer.zig` near other imports, confirm `GlyphUV` is importable. If not, add:
```zig
const GlyphUV = @import("font.zig").Atlas.GlyphUV;
```
(Check the existing import list first — `font.zig` may already be imported under a different name.)

- [ ] **Step 2: Run the tests to verify they fail.**

Run: `zig build test 2>&1 | grep -E 'cursorInstance|FAIL|error'`
Expected: compile error on `@compileError("cursorInstance: not yet implemented")` — that's the intentional failure that proves the test file compiles against the new symbol.

- [ ] **Step 3: Implement `cursorInstance`.**

Replace the function body (keep the signature above; replace the `@compileError`):

```zig
pub fn cursorInstance(
    shape: CursorShape,
    cell_w: u32,
    cell_h: u32,
    buffer_scale: u32,
    uv: GlyphUV,
) Instance {
    const cell_w_f: f32 = @floatFromInt(cell_w);
    const cell_h_f: f32 = @floatFromInt(cell_h);
    const line_w_f: f32 = @floatFromInt(2 * buffer_scale);

    const size_xy: [2]f32 = switch (shape) {
        .block => .{ cell_w_f, cell_h_f },
        .underline => .{ cell_w_f, line_w_f },
        .bar => .{ line_w_f, cell_h_f },
    };
    const bearing_xy: [2]f32 = switch (shape) {
        .block, .bar => .{ 0, 0 },
        .underline => .{ 0, cell_h_f - line_w_f },
    };

    return .{
        .cell_pos = .{ 0, 0 }, // caller overwrites with actual grid position
        .glyph_size = size_xy,
        .glyph_bearing = bearing_xy,
        .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
        .fg = .{ 1.0, 1.0, 1.0, 0.5 },
        .bg = .{ 0, 0, 0, 0 },
    };
}
```

Note: `cell_pos` defaults to `(0, 0)`. The caller overwrites it with the current cursor's viewport position before submitting. This keeps the helper pure (no viewport dependency) and matches how the current inline build works.

- [ ] **Step 4: Run tests to verify they pass.**

Run: `zig build test`
Expected: all tests pass, no warnings.

- [ ] **Step 5: Map ghostty_vt `visual_style` to `renderer.CursorShape` at the call site.**

The current cursor build block at `src/main.zig:552-577` currently hardcodes block. Update it to call the helper, but keep the shape coming from ghostty — `block_hollow` maps to `.block` (fallback, consistent with the spec).

Replace:
```zig
        var cursor_rebuilt = false;
        if (refresh_plan.cursor_rebuild) {
            var cursor_instances_buf: [1]renderer.Instance = undefined;
            var cursor_instances: []const renderer.Instance = &.{};
            if (term.render_state.cursor.viewport) |cursor| {
                const cursor_uv = atlas.cursorUV();
                cursor_instances_buf[0] = .{
                    .cell_pos = .{
                        @floatFromInt(cursor.x),
                        @floatFromInt(cursor.y),
                    },
                    .glyph_size = .{
                        @floatFromInt(cell_w),
                        @floatFromInt(cell_h),
                    },
                    .glyph_bearing = .{ 0, 0 },
                    .uv_rect = .{
                        cursor_uv.u0,
                        cursor_uv.v0,
                        cursor_uv.u1,
                        cursor_uv.v1,
                    },
                    .fg = .{ 1.0, 1.0, 1.0, 0.5 },
                    .bg = .{ 0, 0, 0, 0 },
                };
                cursor_instances = cursor_instances_buf[0..1];
            }
```

with:
```zig
        var cursor_rebuilt = false;
        if (refresh_plan.cursor_rebuild) {
            var cursor_instances_buf: [1]renderer.Instance = undefined;
            var cursor_instances: []const renderer.Instance = &.{};
            if (term.render_state.cursor.viewport) |cursor| {
                const shape: renderer.CursorShape = switch (term.render_state.cursor.visual_style) {
                    .block, .block_hollow => .block,
                    .underline => .underline,
                    .bar => .bar,
                };
                var inst = renderer.cursorInstance(
                    shape,
                    cell_w,
                    cell_h,
                    @as(u32, @intCast(geom.buffer_scale)),
                    atlas.cursorUV(),
                );
                inst.cell_pos = .{
                    @floatFromInt(cursor.x),
                    @floatFromInt(cursor.y),
                };
                cursor_instances_buf[0] = inst;
                cursor_instances = cursor_instances_buf[0..1];
            }
```

- [ ] **Step 6: Run full tests + manual smoke to verify no regression.**

Run: `zig build test`
Expected: all tests pass.

Manual: `zig build run` (or whatever the repo's invocation is; `make run` per CLAUDE.md preference — check the Makefile). Type at the prompt. Cursor should still render as a block, same appearance as before. Then:
- `printf '\e[4 q'` → expect underline cursor (thin bar at cell bottom).
- `printf '\e[6 q'` → expect bar cursor (thin column at cell left).
- `printf '\e[2 q'` → back to block.

(Blink mode ignored until Task 4; shape changes should be instant since any pty byte already sets `render_pending`.)

- [ ] **Step 7: Commit.**

```bash
git add src/renderer.zig src/main.zig
git commit -m "$(cat <<'EOF'
cursor: extract cursorInstance helper with DECSCUSR shape dispatch

Pure helper returning a renderer.Instance for block/underline/bar.
Replaces the inline instance build in main.zig so shape geometry
is unit-testable without a Vulkan device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: Extend `computePollTimeoutMs` to take a second deadline

**Files:**
- Modify: `src/main.zig:794-797` — signature + body
- Modify: `src/main.zig:343-345` — call site
- Modify: `src/main.zig:1633-1637` — existing test; add new cases

**What:** Grow `computePollTimeoutMs` from one nullable deadline-ms to two (repeat + blink). Minimum wins; `render_pending=true` still short-circuits to `0`.

- [ ] **Step 1: Replace the existing test with an expanded set that also covers the new cases.**

At `src/main.zig:1633-1637`, replace:
```zig
test "event loop waits indefinitely when idle and wakes for imminent repeat" {
    try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, false));
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, true));
    try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, false));
}
```

with:
```zig
test "computePollTimeoutMs: idle with no deadlines returns -1" {
    try std.testing.expectEqual(@as(i32, -1), computePollTimeoutMs(null, null, false));
}

test "computePollTimeoutMs: render_pending forces zero regardless of deadlines" {
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(5, null, true));
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(null, 5, true));
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(3, 7, true));
    try std.testing.expectEqual(@as(i32, 0), computePollTimeoutMs(null, null, true));
}

test "computePollTimeoutMs: single deadline passes through" {
    try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, null, false));
    try std.testing.expectEqual(@as(i32, 500), computePollTimeoutMs(null, 500, false));
}

test "computePollTimeoutMs: returns min of two deadlines" {
    try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(17, 500, false));
    try std.testing.expectEqual(@as(i32, 17), computePollTimeoutMs(500, 17, false));
    try std.testing.expectEqual(@as(i32, 7), computePollTimeoutMs(7, 7, false));
}
```

- [ ] **Step 2: Run tests to verify they fail.**

Run: `zig build test 2>&1 | grep -E 'computePollTimeoutMs|FAIL|error'`
Expected: compile failure — `computePollTimeoutMs` takes 2 args, tests now pass 3.

- [ ] **Step 3: Widen the signature and body at `src/main.zig:794-797`.**

Replace:
```zig
fn computePollTimeoutMs(next_repeat_in_ms: ?i32, render_pending: bool) i32 {
    if (render_pending) return 0;
    return next_repeat_in_ms orelse -1;
}
```

with:
```zig
fn computePollTimeoutMs(
    next_repeat_in_ms: ?i32,
    next_blink_in_ms: ?i32,
    render_pending: bool,
) i32 {
    if (render_pending) return 0;
    if (next_repeat_in_ms) |r| {
        if (next_blink_in_ms) |b| return if (r < b) r else b;
        return r;
    }
    return next_blink_in_ms orelse -1;
}
```

Forward-looking note (do not implement): if a third deadline source appears (suspend heartbeat, bell timeout, auto-hide mouse cursor), collapse to `computePollTimeoutMs(render_pending: bool, deadlines: []const ?i32)` and take the min. Filed in the spec's forward-looking section.

- [ ] **Step 4: Update the single call site at `src/main.zig:343-345` with a placeholder `null` for the blink deadline.**

Replace:
```zig
        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        const timeout = computePollTimeoutMs(repeat_timeout_ms, render_pending);
```

with:
```zig
        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        // Blink deadline wired up in Task 4; null for now.
        const timeout = computePollTimeoutMs(repeat_timeout_ms, null, render_pending);
```

This keeps Task 2 green-on-commit. Task 4 flips the `null` to `remainingRepeatTimeoutMs(blink_state.next_deadline_ns)` once `blink_state` exists.

- [ ] **Step 5: Build and run tests.**

Run: `zig build test`
Expected: full binary builds, all tests pass (the four new `computePollTimeoutMs` cases plus everything else).

- [ ] **Step 6: Commit.**

```bash
git add src/main.zig
git commit -m "$(cat <<'EOF'
main: widen computePollTimeoutMs to accept a second deadline

Prep for cursor blink deadline. Call site still passes null for
the blink slot until the blink state machine lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: Add blink pure helpers + draw-rule helper in `src/main.zig`

**Files:**
- Modify: `src/main.zig` — add `BlinkState`, `blink_period_ns`, `tickBlinkPhase`, `reconfigureBlink`, `shouldDrawCursor`, and their unit tests near the other loop helpers (`remainingRepeatTimeoutMs` / `computePollTimeoutMs` at L786-797 are a natural neighborhood).

- [ ] **Step 1: Add the types, constants, and helper stubs + failing tests.**

Directly after the existing `computePollTimeoutMs` at `src/main.zig:797`, insert:

```zig
const blink_period_ns: i128 = 500 * std.time.ns_per_ms;

const BlinkState = struct {
    blink_on: bool = true,
    next_deadline_ns: ?i128 = null,
};

const BlinkPhaseTick = struct {
    state: BlinkState,
    flipped: bool,
};

fn tickBlinkPhase(state: BlinkState, now_ns: i128) BlinkPhaseTick {
    @compileError("tickBlinkPhase: not yet implemented");
}

const BlinkReconfigure = struct {
    state: BlinkState,
    cursor_rebuild: bool,
};

fn reconfigureBlink(
    state: BlinkState,
    want_blink: bool,
    cursor_identity_changed: bool,
    focus_regained: bool,
    now_ns: i128,
) BlinkReconfigure {
    @compileError("reconfigureBlink: not yet implemented");
}

fn shouldDrawCursor(
    visible: bool,
    viewport_present: bool,
    has_focus: bool,
    blinking: bool,
    blink_on: bool,
) bool {
    @compileError("shouldDrawCursor: not yet implemented");
}
```

Then, alongside the other tests near `src/main.zig:1633`, add:

```zig
// --- blink state machine ---

test "tickBlinkPhase: no deadline → no flip" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
    const r = tickBlinkPhase(s, 1_000_000_000);
    try std.testing.expect(!r.flipped);
    try std.testing.expectEqual(s.blink_on, r.state.blink_on);
    try std.testing.expect(r.state.next_deadline_ns == null);
}

test "tickBlinkPhase: deadline in future → no flip" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 2_000_000_000 };
    const r = tickBlinkPhase(s, 1_500_000_000);
    try std.testing.expect(!r.flipped);
    try std.testing.expectEqual(s, r.state);
}

test "tickBlinkPhase: deadline reached → flip, new deadline set at now + 500ms" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 2_000_000_000 };
    const now: i128 = 2_000_000_000;
    const r = tickBlinkPhase(s, now);
    try std.testing.expect(r.flipped);
    try std.testing.expect(!r.state.blink_on);
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
}

test "tickBlinkPhase: deadline overshot → flip (reschedules from now, not deadline)" {
    const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 1_000_000_000 };
    const now: i128 = 5_000_000_000;
    const r = tickBlinkPhase(s, now);
    try std.testing.expect(r.flipped);
    try std.testing.expect(r.state.blink_on); // flipped from off → on
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
}

test "reconfigureBlink: inactive → active arms timer and forces on-phase" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
    const r = reconfigureBlink(s, true, false, false, 10_000_000_000);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expectEqual(@as(?i128, 10_000_000_000 + blink_period_ns), r.state.next_deadline_ns);
}

test "reconfigureBlink: active, no transition → no-op" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 10_500_000_000 };
    const r = reconfigureBlink(s, true, false, false, 10_100_000_000);
    try std.testing.expect(!r.cursor_rebuild);
    try std.testing.expectEqual(s, r.state);
}

test "reconfigureBlink: active → inactive mid off-phase resets to solid" {
    const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
    const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expect(r.state.next_deadline_ns == null);
}

test "reconfigureBlink: active → inactive on-phase still disarms (for future eligibility)" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = 10_500_000_000 };
    const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expect(r.state.next_deadline_ns == null);
}

test "reconfigureBlink: inactive → inactive is a no-op" {
    const s: BlinkState = .{ .blink_on = true, .next_deadline_ns = null };
    const r = reconfigureBlink(s, false, false, false, 10_100_000_000);
    try std.testing.expect(!r.cursor_rebuild);
    try std.testing.expectEqual(s, r.state);
}

test "reconfigureBlink: cursor identity change while active resets phase + extends deadline" {
    const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
    const now: i128 = 10_200_000_000;
    const r = reconfigureBlink(s, true, true, false, now);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
}

test "reconfigureBlink: focus regained while active resets phase" {
    const s: BlinkState = .{ .blink_on = false, .next_deadline_ns = 10_500_000_000 };
    const now: i128 = 10_200_000_000;
    const r = reconfigureBlink(s, true, false, true, now);
    try std.testing.expect(r.cursor_rebuild);
    try std.testing.expect(r.state.blink_on);
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), r.state.next_deadline_ns);
}

// --- draw rule ---

test "shouldDrawCursor: invisible never drawn" {
    try std.testing.expect(!shouldDrawCursor(false, true, true, true, true));
    try std.testing.expect(!shouldDrawCursor(false, false, false, false, false));
}

test "shouldDrawCursor: no viewport never drawn" {
    try std.testing.expect(!shouldDrawCursor(true, false, true, true, true));
}

test "shouldDrawCursor: unfocused → always drawn (solid)" {
    try std.testing.expect(shouldDrawCursor(true, true, false, true, false));
    try std.testing.expect(shouldDrawCursor(true, true, false, true, true));
    try std.testing.expect(shouldDrawCursor(true, true, false, false, false));
}

test "shouldDrawCursor: blink mode off → always drawn (solid)" {
    try std.testing.expect(shouldDrawCursor(true, true, true, false, false));
    try std.testing.expect(shouldDrawCursor(true, true, true, false, true));
}

test "shouldDrawCursor: focused + blinking → follows phase" {
    try std.testing.expect(shouldDrawCursor(true, true, true, true, true));
    try std.testing.expect(!shouldDrawCursor(true, true, true, true, false));
}

// --- integration: poll-timeout + tickBlinkPhase walk ---

test "blink: poll timeout + tickBlinkPhase walk through two full phases" {
    var state: BlinkState = .{ .blink_on = true, .next_deadline_ns = 0 + blink_period_ns };
    var now: i128 = 0;

    // First wake: poll reports "500ms" remaining. Advance clock to deadline.
    const timeout_0 = remainingRepeatTimeoutMs(state.next_deadline_ns);
    try std.testing.expectEqual(@as(?i32, 500), timeout_0);
    now = state.next_deadline_ns.?;
    const tick_0 = tickBlinkPhase(state, now);
    try std.testing.expect(tick_0.flipped);
    try std.testing.expect(!tick_0.state.blink_on);
    state = tick_0.state;

    // Second wake: deadline bumped by 500ms from now.
    try std.testing.expectEqual(@as(?i128, now + blink_period_ns), state.next_deadline_ns);
    now = state.next_deadline_ns.?;
    const tick_1 = tickBlinkPhase(state, now);
    try std.testing.expect(tick_1.flipped);
    try std.testing.expect(tick_1.state.blink_on);
}
```

- [ ] **Step 2: Run tests to verify they fail on the stubs.**

Run: `zig build test 2>&1 | grep -E 'tickBlinkPhase|reconfigureBlink|shouldDrawCursor|FAIL|error' | head -30`
Expected: compile errors from the three `@compileError` stubs.

- [ ] **Step 3: Implement `tickBlinkPhase`.**

Replace the stub body with:

```zig
fn tickBlinkPhase(state: BlinkState, now_ns: i128) BlinkPhaseTick {
    const deadline = state.next_deadline_ns orelse return .{ .state = state, .flipped = false };
    if (now_ns < deadline) return .{ .state = state, .flipped = false };
    return .{
        .state = .{
            .blink_on = !state.blink_on,
            .next_deadline_ns = now_ns + blink_period_ns,
        },
        .flipped = true,
    };
}
```

- [ ] **Step 4: Implement `reconfigureBlink`.**

Replace the stub body with:

```zig
fn reconfigureBlink(
    state: BlinkState,
    want_blink: bool,
    cursor_identity_changed: bool,
    focus_regained: bool,
    now_ns: i128,
) BlinkReconfigure {
    if (want_blink) {
        if (state.next_deadline_ns == null) {
            return .{
                .state = .{ .blink_on = true, .next_deadline_ns = now_ns + blink_period_ns },
                .cursor_rebuild = true,
            };
        }
        if (cursor_identity_changed or focus_regained) {
            return .{
                .state = .{ .blink_on = true, .next_deadline_ns = now_ns + blink_period_ns },
                .cursor_rebuild = true,
            };
        }
        return .{ .state = state, .cursor_rebuild = false };
    }
    // want_blink == false
    if (state.next_deadline_ns != null or !state.blink_on) {
        return .{
            .state = .{ .blink_on = true, .next_deadline_ns = null },
            .cursor_rebuild = true,
        };
    }
    return .{ .state = state, .cursor_rebuild = false };
}
```

- [ ] **Step 5: Implement `shouldDrawCursor`.**

Replace the stub body with:

```zig
fn shouldDrawCursor(
    visible: bool,
    viewport_present: bool,
    has_focus: bool,
    blinking: bool,
    blink_on: bool,
) bool {
    if (!visible) return false;
    if (!viewport_present) return false;
    if (!has_focus) return true;
    if (!blinking) return true;
    return blink_on;
}
```

- [ ] **Step 6: Run all tests.**

Run: `zig build test`
Expected: all blink + draw-rule + integration tests pass. Existing tests unaffected.

- [ ] **Step 7: Commit.**

```bash
git add src/main.zig
git commit -m "$(cat <<'EOF'
main: add blink state machine + draw-rule pure helpers

tickBlinkPhase, reconfigureBlink, shouldDrawCursor as pure
functions with unit tests. Next commit wires them into the
main loop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: Wire blink into the main loop

**Files:**
- Modify: `src/main.zig` — main loop at the `runTerminal` function. Changes at: locals block (~L336-340), poll-timeout call (~L343-345), after-poll flip (~new insertion after L345), after-snapshot reconfigure + identity detection (~inserted around L492-518), cursor instance branch at L553-577 (tighten to shouldDrawCursor), end-of-iter bookkeeping (new).

This is the integration task. No new pure functions — just plumbing. The helpers from Tasks 1 and 3 do the thinking.

- [ ] **Step 1: Add new locals in the `runTerminal` locals block.**

Just after `var consecutive_vk_timeouts: u32 = 0;` at `src/main.zig:340`, add:

```zig
    var blink_state: BlinkState = .{};
    var previous_has_focus: bool = keyboard.has_focus;
```

- [ ] **Step 1b: Flip the Task-2 placeholder `null` to the real blink deadline.**

At `src/main.zig:343-345` (edited in Task 2 Step 4), change:
```zig
        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        // Blink deadline wired up in Task 4; null for now.
        const timeout = computePollTimeoutMs(repeat_timeout_ms, null, render_pending);
```
to:
```zig
        const repeat_timeout_ms = remainingRepeatTimeoutMs(keyboard.nextRepeatDeadlineNs());
        const blink_timeout_ms = remainingRepeatTimeoutMs(blink_state.next_deadline_ns);
        const timeout = computePollTimeoutMs(repeat_timeout_ms, blink_timeout_ms, render_pending);
```

- [ ] **Step 2: Flip phase right after `waitForWork` returns.**

Immediately after the `try frame_loop.waitForWork(&pollfds_extra, timeout);` call at `src/main.zig:345`, insert:

```zig
        // Phase-flip first so a flipped-this-tick deadline marks render_pending
        // before the `if (!render_pending) continue;` bail below.
        const tick = tickBlinkPhase(blink_state, std.time.nanoTimestamp());
        blink_state = tick.state;
        var cursor_rebuild_from_blink = tick.flipped;
        if (tick.flipped) render_pending = true;
```

`cursor_rebuild_from_blink` is function-scope mutable because Step 4 below OR's more work into it after snapshot.

- [ ] **Step 3: Bail-and-continue path unchanged.**

The `if (!render_pending) continue;` at `src/main.zig:423` stays as-is. If blink didn't flip and nothing else set `render_pending`, we continue and the next poll will sleep until the blink deadline (thanks to Task 2 folding `blink_state.next_deadline_ns` into the timeout).

- [ ] **Step 4: After `term.snapshot()`, reconfigure the timer and detect identity change.**

Find the block at `src/main.zig:492-494`:
```zig
        const previous_cursor = term.render_state.cursor;
        var section_timer = std.time.Timer.start() catch unreachable;
        try term.snapshot();
```

Add, directly after the `try term.snapshot();` line:

```zig
        // Recompute blink arm/disarm from current state. Edge-driven updates
        // are deliberately avoided — `want_blink` is a pure function of the
        // state we see right now, so no transition can be "missed".
        const current_cursor = term.render_state.cursor;
        const viewport_present = current_cursor.viewport != null;
        const want_blink = current_cursor.visible
            and viewport_present
            and current_cursor.blinking
            and keyboard.has_focus;

        const cursor_identity_changed =
            !std.meta.eql(current_cursor.viewport, previous_cursor.viewport)
            or current_cursor.visual_style != previous_cursor.visual_style
            or current_cursor.visible != previous_cursor.visible
            or current_cursor.blinking != previous_cursor.blinking;
        const focus_regained = keyboard.has_focus and !previous_has_focus;

        const reconf = reconfigureBlink(
            blink_state,
            want_blink,
            cursor_identity_changed,
            focus_regained,
            std.time.nanoTimestamp(),
        );
        blink_state = reconf.state;
        if (reconf.cursor_rebuild) cursor_rebuild_from_blink = true;
```

Note: `std.meta.eql` on `?Viewport` works because `Viewport` is a plain struct of integers/bool.

- [ ] **Step 5: OR the blink rebuild into the cursor-rebuild decision at the plan call site.**

After `planRowRefresh(...)` returns into `refresh_plan` (currently `src/main.zig:505-518`), add one line right after the `refresh_plan` assignment:

```zig
        const cursor_rebuild_effective = refresh_plan.cursor_rebuild or cursor_rebuild_from_blink;
```

Then change the cursor-rebuild branch gate at `src/main.zig:553`:
```zig
        if (refresh_plan.cursor_rebuild) {
```
to:
```zig
        if (cursor_rebuild_effective) {
```

Rationale: we don't extend `RowRefreshPlan` or `CursorRefreshContext` — the blink input is an orthogonal trigger and lives outside the planner.

- [ ] **Step 6: Apply `shouldDrawCursor` inside the cursor-rebuild branch.**

Inside the branch at `src/main.zig:553-577` (already minimally rewritten in Task 1 Step 5), tighten the `if (term.render_state.cursor.viewport) |cursor|` check to a `shouldDrawCursor` check so blink-off and unfocused-when-hidden all converge on "no instance."

Replace the current:
```zig
            if (term.render_state.cursor.viewport) |cursor| {
                const shape: renderer.CursorShape = switch (term.render_state.cursor.visual_style) {
                    .block, .block_hollow => .block,
                    .underline => .underline,
                    .bar => .bar,
                };
                var inst = renderer.cursorInstance(
                    shape,
                    cell_w,
                    cell_h,
                    @as(u32, @intCast(geom.buffer_scale)),
                    atlas.cursorUV(),
                );
                inst.cell_pos = .{
                    @floatFromInt(cursor.x),
                    @floatFromInt(cursor.y),
                };
                cursor_instances_buf[0] = inst;
                cursor_instances = cursor_instances_buf[0..1];
            }
```

with:
```zig
            const draw_cursor = shouldDrawCursor(
                current_cursor.visible,
                current_cursor.viewport != null,
                keyboard.has_focus,
                current_cursor.blinking,
                blink_state.blink_on,
            );
            if (draw_cursor) {
                const cursor = current_cursor.viewport.?; // guaranteed by shouldDrawCursor
                const shape: renderer.CursorShape = switch (current_cursor.visual_style) {
                    .block, .block_hollow => .block,
                    .underline => .underline,
                    .bar => .bar,
                };
                var inst = renderer.cursorInstance(
                    shape,
                    cell_w,
                    cell_h,
                    @as(u32, @intCast(geom.buffer_scale)),
                    atlas.cursorUV(),
                );
                inst.cell_pos = .{
                    @floatFromInt(cursor.x),
                    @floatFromInt(cursor.y),
                };
                cursor_instances_buf[0] = inst;
                cursor_instances = cursor_instances_buf[0..1];
            }
```

- [ ] **Step 7: Update `previous_has_focus` at end of iteration.**

Find the tail of the main `while` loop body (just before the closing `}` of the `while (!window.should_close and p.isChildAlive())` block — after `frame_ring.push(frame_timing);` at `src/main.zig:719`). Add:

```zig
        previous_has_focus = keyboard.has_focus;
```

- [ ] **Step 8: Build and run all tests.**

Run: `zig build test`
Expected: everything passes. If the `cursor_identity_changed` `std.meta.eql` call complains about the `?Viewport` type not being comparable, replace with the manual form:
```zig
const cursor_identity_changed =
    (current_cursor.viewport == null) != (previous_cursor.viewport == null)
    or (current_cursor.viewport != null and previous_cursor.viewport != null
        and (current_cursor.viewport.?.x != previous_cursor.viewport.?.x
             or current_cursor.viewport.?.y != previous_cursor.viewport.?.y
             or current_cursor.viewport.?.wide_tail != previous_cursor.viewport.?.wide_tail))
    or current_cursor.visual_style != previous_cursor.visual_style
    or current_cursor.visible != previous_cursor.visible
    or current_cursor.blinking != previous_cursor.blinking;
```

- [ ] **Step 9: Manual smoke.**

Build and run waystty. From inside, test each branch of the spec:

1. **Baseline block blink, focused:** open waystty. Cursor blinks at 500 ms.
2. **Blink off via mode:** `printf '\e[?12l'`. Cursor steady.
3. **Blink on via mode:** `printf '\e[?12h'`. Blink resumes.
4. **Shape via DECSCUSR:**
   - `printf '\e[1 q'` → blinking block.
   - `printf '\e[2 q'` → steady block.
   - `printf '\e[3 q'` → blinking underline.
   - `printf '\e[4 q'` → steady underline.
   - `printf '\e[5 q'` → blinking bar.
   - `printf '\e[6 q'` → steady bar.
5. **Hide / show:** `printf '\e[?25l'` → cursor vanishes. `\e[?25h` → cursor returns on-phase.
6. **Focus:** click away from waystty. Cursor steady. Click back. Cursor reappears in on-phase immediately.
7. **Cursor move resets phase:** while blinking, press `left`/`right` — observe each press leaves the cursor visible for a fresh 500 ms before the next flip.
8. **Suspend/resume (best effort — only works when blink mode is on at sleep time):** leave waystty at a shell prompt (blink enabled). Sleep the machine (e.g. `systemctl suspend`). Wake. Cursor should still be blinking; if nothing on screen, move the mouse or press a key — recovery via `OutOfDateKHR` + swapchain recreate should land within 500 ms of the first subsequent render attempt. Known caveat (in spec's non-goals): if blink was disabled at sleep time, the loop still wedges.

- [ ] **Step 10: Commit.**

```bash
git add src/main.zig
git commit -m "$(cat <<'EOF'
main: wire blink state machine into the render loop

Deadline folded into computePollTimeoutMs alongside key-repeat.
tickBlinkPhase runs right after poll() so a just-flipped tick
sets render_pending before the idle-bail. reconfigureBlink runs
post-snapshot and detects cursor/focus identity changes against
previous_cursor + previous_has_focus.

Draw rule now gated on shouldDrawCursor; DECSCUSR shapes dispatch
into renderer.cursorInstance.

As a side effect the 500ms tick gives the main loop a heartbeat
whenever blink is enabled, which is enough to recover from a
suspend wedge in the common interactive case (bare shell). See
docs/superpowers/specs/2026-04-18-cursor-blink-design.md for the
limit on that benefit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Post-implementation: run the grep gate

The existing `tests/check_unbounded_vk.sh` gate is unaffected because no Vulkan sites change. Confirm:

- [ ] `bash tests/check_unbounded_vk.sh` — expected: pass.

---

## Spec coverage check (done while writing this plan)

| Spec section | Task |
|---|---|
| Goal: blink + DECSCUSR + 500ms | Tasks 1, 3, 4 |
| Non-goals acknowledged in commit message | Task 4 Step 10 |
| Architecture: state machine as main-loop locals | Task 4 Step 1 |
| Architecture: poll timeout extended | Task 2 |
| Architecture: cursorInstance in renderer | Task 1 |
| Draw rule | Task 3 (tests) + Task 4 Step 6 (applied) |
| Timer state machine: arm/disarm | Task 3 (reconfigureBlink) + Task 4 Step 4 |
| Phase flip | Task 3 (tickBlinkPhase) + Task 4 Step 2 |
| Cursor identity / focus-regain reset | Task 4 Step 4 |
| Rebuild plumbing: OR at call site, not in planRowRefresh | Task 4 Step 5 |
| Shape dispatch table | Task 1 Step 3 |
| Unit tests per helper | Tasks 1, 3 |
| Integration-ish test: tickBlinkPhase + computePollTimeoutMs | Task 3 Step 1 (last block) |
| Manual smoke list | Task 4 Step 9 |
| Forward note on collapsing to varargs | Task 2 Step 3 comment |