a73x

85df18a0

Add corrections section to waystty implementation plan

a73x   2026-04-08 05:40

Commit message
Add corrections section to waystty implementation plan

Addresses review feedback without rewriting the plan:

Blockers fixed (C1-C2, C7-C10, C12-C13):
- Zig 0.15 stdlib API sweep (GPA, ArrayList, io.getStdOut, fcntl flags)
- Task 0.2/0.3 ordering (build.zig must exist before zig fetch --save)
- @cImport moved out of function body
- mmap defer-before-null-check fixed
- String literal casts for fontconfig/xkbcommon C APIs
- FT_Bitmap null check + negative pitch handling
- execve plumbing via std.posix.execveZ
- Task 2.1 path discovery via ls instead of find

Missing coverage (C3-C6, C11):
- New Task 2.8: wire effect callbacks (WRITE_PTY, TITLE_CHANGED, etc.)
- New Task 3.2: SIGCHLD / child-exit detection (terminal now exits
  when shell exits)
- New Task 7.6: cursor rendering (previously no visible cursor)
- sRGB color math documented
- Key repeat carries current modifier state

Structure (C14-C15):
- Guidance to split 6.7, 6.8, 7.2, 7.3 into sub-tasks ad-hoc
- Drop unused protocol bindings from Task 5.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

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