85df18a0
Add corrections section to waystty implementation plan
a73x 2026-04-08 05:40
Commit message
docs/superpowers/plans/2026-04-07-waystty-implementation.md
| Old | New | ||
|---|---|---|---|
| @@ -26,6 +26,232 @@ When in doubt, consult these external references: | |||
| 26 | 26 | ||
| 27 | --- | 27 | --- |
| 28 | 28 | ||
| 29 | ## Corrections (apply while executing) | ||
| 30 | |||
| 31 | This plan was reviewed after writing. The following corrections apply throughout — read them before starting, and apply them when you reach the relevant task. | ||
| 32 | |||
| 33 | ### C1. Zig 0.15 stdlib API sweep | ||
| 34 | |||
| 35 | Multiple tasks use pre-0.15 Zig idioms. Apply these substitutions **everywhere**: | ||
| 36 | |||
| 37 | - **`std.heap.GeneralPurposeAllocator(.{}){}`** → use `std.heap.DebugAllocator(.{}){}` for debug builds, or `std.heap.smp_allocator` for release. The GPA was renamed to `DebugAllocator` in 0.15. | ||
| 38 | - **`std.io.getStdOut().writer()`** → use `std.fs.File.stdout().writer(&buf)` with an explicit `[4096]u8` buffer. Writergate changed the writer API in 0.15. (Affects Task 3.1.) | ||
| 39 | - **`std.ArrayList(T).init(alloc)`** → ArrayList is unmanaged by default in 0.15. Use `std.ArrayList(T){}` or `.empty` as the initial value. Methods take an allocator: `list.append(alloc, item)`, `list.deinit(alloc)`. (Affects Task 5.4, 5.5, 7.3.) | ||
| 40 | - **`std.posix.fcntl(..., flags | @as(u32, @bitCast(std.posix.O{...})))`** → the `flags` returned by fcntl is wider than `u32` on 64-bit. Use `std.posix.O.NONBLOCK` via the typed wrapper, or bitcast through `@as(@TypeOf(flags), ...)`. (Affects Task 1.1.) | ||
| 41 | |||
| 42 | ### C2. Task 0.2 / 0.3 ordering is wrong | ||
| 43 | |||
| 44 | `zig fetch --save` requires a valid `build.zig` to exist. The order must be: | ||
| 45 | |||
| 46 | 1. **Task 0.2 (revised)**: Write `build.zig.zon` with an empty `.dependencies = .{}` block and placeholder fingerprint `0x0`. Do NOT run `zig fetch` yet. | ||
| 47 | 2. **Task 0.3**: Write `build.zig` and `src/main.zig`. | ||
| 48 | 3. **Task 0.3 extra step**: Run `zig build` — it will fail with a fingerprint error. Paste the printed fingerprint value back into `build.zig.zon`. Run `zig build` again; it should succeed and print `waystty`. | ||
| 49 | 4. **Task 0.2b (new step)**: Run the `zig fetch --save` commands to populate dependencies. Each will update `build.zig.zon` with real hashes. | ||
| 50 | 5. **Task 0.2c**: Pin ghostty to a specific commit hash by editing the URL to `git+https://github.com/ghostty-org/ghostty#<commit-sha>`. The spec requires pinning. | ||
| 51 | |||
| 52 | ### C3. Add Task 2.8 — wire effect callbacks | ||
| 53 | |||
| 54 | The spec lists six required effect callbacks. The plan omits this step, which means `vim` cursor positioning will break (needs `WRITE_PTY`), titles won't update, and DA queries won't respond. Add this task after Task 2.7: | ||
| 55 | |||
| 56 | **Task 2.8: Wire effect callbacks** | ||
| 57 | |||
| 58 | - [ ] Register `WRITE_PTY` callback that writes bytes to a provided `*Pty` via a context pointer. **Mandatory** — without this, the terminal cannot respond to DSR or cursor-position queries. | ||
| 59 | - [ ] Register `DEVICE_ATTRIBUTES` callback returning VT220 conformance. | ||
| 60 | - [ ] Register `SIZE` callback that returns current cols/rows. | ||
| 61 | - [ ] Register `XTVERSION` callback returning `"waystty"`. | ||
| 62 | - [ ] Register `TITLE_CHANGED` callback that stores the new title in a shared string. `main.zig` reads this after each frame and calls `xdg_toplevel.setTitle` when it changes. | ||
| 63 | - [ ] Register `COLOR_SCHEME` callback as a stub returning `false`. | ||
| 64 | |||
| 65 | The exact Zig API for registering these callbacks must be determined from `example/zig-vt/src/main.zig`. The C API uses `ghostty_terminal_set(term, OPT, callback)`; the Zig module likely exposes these as fields on an options struct passed to `Terminal.init`, or as individual setter methods. | ||
| 66 | |||
| 67 | Add a test that feeds a DSR query (`\x1b[6n`) and verifies the write-pty callback fires with a cursor-position response. | ||
| 68 | |||
| 69 | ### C4. Add Task 3.2 — SIGCHLD / child-exit detection | ||
| 70 | |||
| 71 | The plan's main loop checks `p.child_pid > 0`, but `child_pid` never changes after spawn — the loop runs forever after the shell exits. Fix: | ||
| 72 | |||
| 73 | **Task 3.2: Detect child exit** | ||
| 74 | |||
| 75 | - [ ] Add a `waitpidNonblocking` helper to `pty.zig`: | ||
| 76 | |||
| 77 | ```zig | ||
| 78 | pub fn isChildAlive(self: *Pty) bool { | ||
| 79 | var status: c_int = 0; | ||
| 80 | const rc = c.waitpid(self.child_pid, &status, c.WNOHANG); | ||
| 81 | if (rc == 0) return true; // still running | ||
| 82 | if (rc == self.child_pid) return false; // exited | ||
| 83 | return true; // error or no state change | ||
| 84 | } | ||
| 85 | ``` | ||
| 86 | |||
| 87 | - [ ] In the main loop (Task 7.3), replace `while (!window.should_close and p.child_pid > 0)` with `while (!window.should_close and p.isChildAlive())`. | ||
| 88 | - [ ] When `Pty.read` returns `0` (not `error.WouldBlock` — a clean `0` means EOF), break out of the loop. Add this check in both Task 3.1's `runHeadless` (already handles it) and Task 7.3's main loop (currently doesn't). | ||
| 89 | |||
| 90 | ### C5. Add Task 7.6 — cursor rendering | ||
| 91 | |||
| 92 | The plan never draws a cursor. libghostty's render state exposes cursor position and visibility. Add after Task 7.5: | ||
| 93 | |||
| 94 | **Task 7.6: Render cursor** | ||
| 95 | |||
| 96 | - [ ] Query cursor x/y/visible from `render_state` after each `update`. | ||
| 97 | - [ ] If visible, append one extra `Instance` to the instance buffer at the cursor cell with inverted fg/bg colors (or a hardcoded bright color). Use a special UV rect pointing to an all-1.0 region of the atlas (reserve a 1x1 white pixel at atlas `(0,0)` during `Atlas.init`). | ||
| 98 | - [ ] Draw the cursor instance as part of the same draw call. | ||
| 99 | |||
| 100 | Add the white-pixel reservation to Task 4.3: after `atlas.pixels` is zeroed, set `atlas.pixels[0] = 255` and reserve cursor UV coordinates at `(0, 0)` to `(1/width, 1/height)`. | ||
| 101 | |||
| 102 | ### C6. sRGB color correctness (Task 6.5 + 6.7) | ||
| 103 | |||
| 104 | Mixing sRGB-encoded colors with linear glyph alpha produces visually wrong text. Pick one: | ||
| 105 | |||
| 106 | - **Option A (recommended)**: Change swapchain format from `.b8g8r8a8_srgb` to `.b8g8r8a8_unorm`, and manually linearize cell colors before passing to the shader (`pow(x, 2.2)` approximation, or a proper sRGB→linear function), then the shader's `mix` works in linear space. Output also needs to be gamma-corrected before write. | ||
| 107 | - **Option B (simpler, slightly wrong)**: Keep sRGB swapchain. Do `mix` in sRGB space (visually acceptable for terminal text, even if mathematically wrong). Document the choice. | ||
| 108 | |||
| 109 | The plan originally assumed B implicitly; the review correctly flagged it. Choose B for v1 simplicity and document the limitation. | ||
| 110 | |||
| 111 | ### C7. Task 7.3 — fix `@cImport` in function body | ||
| 112 | |||
| 113 | `@cImport` inside a function body is a compile error. Move the dlopen wrapper to module scope: | ||
| 114 | |||
| 115 | ```zig | ||
| 116 | // At top of main.zig (not inside main) | ||
| 117 | const dl = @cImport({ | ||
| 118 | @cInclude("dlfcn.h"); | ||
| 119 | }); | ||
| 120 | |||
| 121 | var vk_lib_handle: ?*anyopaque = null; | ||
| 122 | |||
| 123 | fn vkGetInstanceProcAddr(instance: anytype, name: [*:0]const u8) ?*const fn () callconv(.C) void { | ||
| 124 | if (vk_lib_handle == null) { | ||
| 125 | vk_lib_handle = dl.dlopen("libvulkan.so.1", dl.RTLD_NOW); | ||
| 126 | } | ||
| 127 | const handle = vk_lib_handle orelse return null; | ||
| 128 | const sym = dl.dlsym(handle, "vkGetInstanceProcAddr") orelse return null; | ||
| 129 | const get_proc: *const fn (@TypeOf(instance), [*:0]const u8) callconv(.C) ?*const fn () callconv(.C) void = | ||
| 130 | @ptrCast(@alignCast(sym)); | ||
| 131 | return get_proc(instance, name); | ||
| 132 | } | ||
| 133 | ``` | ||
| 134 | |||
| 135 | The exact signature vulkan-zig expects must still be matched; consult its examples. Key fix: cImport and `vk_lib_handle` live at module scope. | ||
| 136 | |||
| 137 | ### C8. Keyboard event queue | ||
| 138 | |||
| 139 | Task 5.4 uses `ArrayList(KeyboardEvent).init(alloc)`. Apply C1 (unmanaged ArrayList), plus: | ||
| 140 | |||
| 141 | - Pre-reserve capacity of 64 at init so `append` rarely allocates in the event callback. | ||
| 142 | - If `append` fails (OOM or otherwise), log via `std.log.err` rather than silently dropping — this is the only path for user input. | ||
| 143 | |||
| 144 | ### C9. Task 5.4 mmap defer ordering + xkbcommon casts | ||
| 145 | |||
| 146 | ```zig | ||
| 147 | .keymap => |k| { | ||
| 148 | if (k.format != .xkb_v1) return; | ||
| 149 | const map_mem = c.mmap( | ||
| 150 | null, | ||
| 151 | k.size, | ||
| 152 | c.PROT_READ, | ||
| 153 | c.MAP_PRIVATE, | ||
| 154 | k.fd, | ||
| 155 | 0, | ||
| 156 | ); | ||
| 157 | if (map_mem == c.MAP_FAILED) { | ||
| 158 | _ = c.close(k.fd); | ||
| 159 | return; | ||
| 160 | } | ||
| 161 | defer _ = c.munmap(map_mem, k.size); | ||
| 162 | defer _ = c.close(k.fd); | ||
| 163 | |||
| 164 | const new_keymap = c.xkb_keymap_new_from_string( | ||
| 165 | kb.xkb_ctx, | ||
| 166 | @ptrCast(@alignCast(map_mem.?)), | ||
| 167 | c.XKB_KEYMAP_FORMAT_TEXT_V1, | ||
| 168 | c.XKB_KEYMAP_COMPILE_NO_FLAGS, | ||
| 169 | ) orelse return; | ||
| 170 | // ... | ||
| 171 | }, | ||
| 172 | ``` | ||
| 173 | |||
| 174 | Also cast modifier name strings explicitly: | ||
| 175 | |||
| 176 | ```zig | ||
| 177 | .ctrl = c.xkb_state_mod_name_is_active(state, @ptrCast("Control"), c.XKB_STATE_MODS_EFFECTIVE) > 0, | ||
| 178 | .shift = c.xkb_state_mod_name_is_active(state, @ptrCast("Shift"), c.XKB_STATE_MODS_EFFECTIVE) > 0, | ||
| 179 | ``` | ||
| 180 | |||
| 181 | Same for all `FcPatternAddString` string literals in Task 4.1. | ||
| 182 | |||
| 183 | ### C10. Task 4.2 — FT_Bitmap nullability and negative pitch | ||
| 184 | |||
| 185 | ```zig | ||
| 186 | if (w > 0 and h > 0) { | ||
| 187 | const buffer = bitmap.buffer orelse return error.FtBitmapNull; | ||
| 188 | const pitch: i32 = bitmap.pitch; | ||
| 189 | const abs_pitch: u32 = @intCast(@abs(pitch)); | ||
| 190 | const top_down = pitch > 0; | ||
| 191 | |||
| 192 | var y: u32 = 0; | ||
| 193 | while (y < h) : (y += 1) { | ||
| 194 | const src_y = if (top_down) y else (h - 1 - y); | ||
| 195 | const src_row = buffer + @as(usize, src_y) * abs_pitch; | ||
| 196 | const dst_row = pixels.ptr + @as(usize, y) * w; | ||
| 197 | @memcpy(dst_row[0..w], src_row[0..w]); | ||
| 198 | } | ||
| 199 | } | ||
| 200 | ``` | ||
| 201 | |||
| 202 | ### C11. Task 5.5 — repeat events must carry modifiers | ||
| 203 | |||
| 204 | `tickRepeat` creates synthetic events with `modifiers = .{}`. This breaks `Ctrl-c` repeat. Change `Keyboard` to track `current_mods: Modifiers` (updated in the `.modifiers` handler) and use it when constructing the synthetic repeat event. | ||
| 205 | |||
| 206 | ### C12. Task 7.3 — child process `execve` plumbing | ||
| 207 | |||
| 208 | Use `std.posix.execveZ` instead of `std.c.execve` directly — it handles the type plumbing correctly: | ||
| 209 | |||
| 210 | ```zig | ||
| 211 | if (pid == 0) { | ||
| 212 | _ = c.setenv("TERM", "xterm-256color", 1); | ||
| 213 | var argv = [_:null]?[*:0]const u8{ @ptrCast(opts.shell.ptr), null }; | ||
| 214 | std.posix.execveZ(@ptrCast(opts.shell.ptr), &argv, std.c.environ) catch {}; | ||
| 215 | std.process.exit(1); | ||
| 216 | } | ||
| 217 | ``` | ||
| 218 | |||
| 219 | Assume the caller passes a `[:0]const u8` shell path (tighten `SpawnOptions.shell` to `[:0]const u8`). | ||
| 220 | |||
| 221 | ### C13. Task 2.1 — path discovery | ||
| 222 | |||
| 223 | Instead of `find`, use the deterministic path: | ||
| 224 | |||
| 225 | ```bash | ||
| 226 | ls .zig-cache/p/ | grep ghostty | ||
| 227 | ``` | ||
| 228 | |||
| 229 | If the ghostty dep is lazy and hasn't been fetched yet, run `zig build` first (Task 0.4 triggers it). Then: | ||
| 230 | |||
| 231 | ```bash | ||
| 232 | ls .zig-cache/p/<ghostty-hash>/example/zig-vt/src/ | ||
| 233 | ``` | ||
| 234 | |||
| 235 | ### C14. Split the huge tasks (6.7, 6.8, 7.2, 7.3) | ||
| 236 | |||
| 237 | Each of these should be split into 4-6 sub-tasks with a build check after each sub-task. For example, Task 6.7 splits into: | ||
| 238 | |||
| 239 | - 6.7a: Shader module creation | ||
| 240 | - 6.7b: Vertex input description + binding | ||
| 241 | - 6.7c: Rasterizer + multisampling + color blend | ||
| 242 | - 6.7d: Pipeline layout + push constants | ||
| 243 | - 6.7e: Final `createGraphicsPipelines` call and smoke-compile | ||
| 244 | |||
| 245 | Task 7.2's Context is even larger — split into init (with one subsystem per sub-task: instance, device, swapchain, render pass, pipeline, descriptors, commands, sync, buffers, atlas) and deinit. | ||
| 246 | |||
| 247 | When executing, the implementer should perform this split ad-hoc rather than following the monolithic steps as-written. | ||
| 248 | |||
| 249 | ### C15. Drop unused protocol bindings (nit) | ||
| 250 | |||
| 251 | Task 5.1 binds `wp_cursor_shape_manager_v1`, `wp_fractional_scale_manager_v1`, `wp_viewporter`, `wl_output`. The plan never uses them. For a truly minimal v1, drop them from the scanner block and re-add when actually implementing cursor shape / DPI scaling. | ||
| 252 | |||
| 253 | --- | ||
| 254 | |||
| 29 | ## Phase 0: Project Scaffolding | 255 | ## Phase 0: Project Scaffolding |
| 30 | 256 | ||
| 31 | ### Task 0.1: Initialize project and git | 257 | ### Task 0.1: Initialize project and git |