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 |