Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,28 +52,39 @@ This applies to all SDL3 constants: key codes (SDLK_*), modifier flags (SDL_KMOD
## Zig Language Gotchas

### Type Inference with Builtin Functions
**Problem:** Zig's builtin functions like `@min`, `@max`, and `@clamp` infer result types from their operands. When using comptime constants, this can produce unexpectedly narrow types that cause silent integer wrapping.
**Problem:** Zig's builtin functions like `@min`, `@max`, and `@clamp` infer result types from their operands. When both operands include small comptime constants, the result type can be unexpectedly narrow. If subsequent arithmetic also uses comptime constants, the entire expression stays in the narrow type and can overflow.

**Example Bug:**
```zig
// WRONG: @min infers u2 (2-bit type) from the constant 2, wrapping at 4
const grid_col = @min(@as(usize, col_index), GRID_COLS - 1); // if GRID_COLS=3
const result = row * GRID_COLS + grid_col; // 1*3+1 = 4, wraps to 0 in u2!
const GRID_COLS = 3; // comptime_int (not usize!)
const GRID_ROWS = 3;

// @min infers u2 from the comptime constant (GRID_COLS - 1 = 2)
const grid_col = @min(@as(usize, col_index), GRID_COLS - 1); // u2
const grid_row = @min(@as(usize, row_index), GRID_ROWS - 1); // u2

// WRONG: entire expression stays u2 because GRID_COLS is comptime_int
const result = grid_row * GRID_COLS + grid_col;
// 1 * 3 + 2 = 5, but u2 max is 3 → panic in debug, wraps to 1 in release!
```

**Solution:** Explicitly cast comptime constants to the desired type:
**Solution:** Use explicit `: usize` annotation to force the result type:
```zig
// CORRECT: Both operands are usize, result is usize
const GRID_COLS = 3;
const GRID_ROWS = 3;

// Explicit usize annotation prevents narrow type inference
const grid_col: usize = @min(@as(usize, col_index), @as(usize, GRID_COLS - 1));
const result = row * GRID_COLS + grid_col; // Works correctly
const grid_row: usize = @min(@as(usize, row_index), @as(usize, GRID_ROWS - 1));
const result = grid_row * GRID_COLS + grid_col; // usize, works correctly
```

**When to be careful:**
- Using `@min`, `@max`, `@clamp` with comptime integer literals or constants
- Arithmetic operations where the result might exceed the inferred type's range
- Index calculations, especially for grids or arrays (values 0-3 fit in u2, but 4+ wrap)
- Subsequent arithmetic with comptime constants (they peer-resolve with narrow types)
- Index calculations for grids or arrays

**General rule:** When working with indices, sizes, or any value that might grow, explicitly annotate or cast to `usize` or an appropriate sized type.
**General rule:** When calculating indices or sizes, add explicit `: usize` type annotations to `@min`/`@max` results.

### Naming collisions in large render functions
- When hoisting shared locals (e.g., `cursor`) to wider scopes inside long functions, avoid re-declaring them later with the same name. Zig treats this as shadowing and fails compilation. Prefer a single binding per logical value or choose distinct names for nested scopes to prevent “local constant shadows” errors.
Expand Down
24 changes: 18 additions & 6 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -822,7 +822,9 @@ pub fn main() !void {
const focused = &sessions[anim_state.focused_session];
if (focused.spawned and !focused.dead and focused.shell != null) {
const esc_byte: [1]u8 = .{27};
_ = focused.shell.?.write(&esc_byte) catch {};
_ = focused.shell.?.write(&esc_byte) catch |err| {
log.warn("session {d}: failed to send escape key: {}", .{ anim_state.focused_session, err });
};
}
std.debug.print("Escape released, sent to terminal\n", .{});
}
Expand Down Expand Up @@ -1036,7 +1038,9 @@ pub fn main() !void {
while (i < count) : (i += 1) {
const n = input.encodeMouseScroll(direction, cell.col, cell.row, sgr_format, &buf);
if (n > 0) {
session.sendInput(buf[0..n]) catch {};
session.sendInput(buf[0..n]) catch |err| {
log.warn("session {d}: failed to send mouse scroll: {}", .{ session_idx, err });
};
}
}
} else {
Expand Down Expand Up @@ -1233,7 +1237,9 @@ pub fn main() !void {
// Update cwd to the new worktree path for UI purposes.
const new_path = std.fs.path.join(allocator, &.{ create_action.base_path, ".architect", create_action.name }) catch null;
if (new_path) |abs| {
session.recordCwd(abs) catch {};
session.recordCwd(abs) catch |err| {
log.warn("session {d}: failed to record cwd: {}", .{ create_action.session, err });
};
allocator.free(abs);
}

Expand Down Expand Up @@ -1929,15 +1935,19 @@ fn startSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void {
session.selection_pending = false;

terminal.screens.active.clearSelection();
terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch {};
terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| {
log.warn("session {d}: failed to start selection: {}", .{ session.id, err });
};
session.dirty = true;
}

fn updateSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void {
if (!session.selection_dragging) return;
const anchor = session.selection_anchor orelse return;
const terminal = session.terminal orelse return;
terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch {};
terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| {
log.warn("session {d}: failed to update selection: {}", .{ session.id, err });
};
session.dirty = true;
}

Expand Down Expand Up @@ -2440,7 +2450,9 @@ fn clearTerminal(session: *SessionState) void {
session.dirty = true;

// Trigger shell redraw like Ghostty (FF) so the prompt is repainted at top.
session.sendInput(&[_]u8{0x0C}) catch {};
session.sendInput(&[_]u8{0x0C}) catch |err| {
log.warn("session {d}: failed to send clear redraw: {}", .{ session.id, err });
};
}

fn copySelectionToClipboard(
Expand Down
18 changes: 14 additions & 4 deletions src/session/notify.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const posix = std.posix;
const app_state = @import("../app/app_state.zig");
const atomic = std.atomic;

const log = std.log.scoped(.notify);

pub const Notification = struct {
session: usize,
state: app_state.SessionStatus,
Expand Down Expand Up @@ -104,14 +106,18 @@ pub fn startNotifyThread(
try posix.bind(fd, &addr.any, addr.getOsSockLen());
try posix.listen(fd, 16);
const sock_path = std.mem.sliceTo(ctx.socket_path, 0);
_ = std.posix.fchmodat(posix.AT.FDCWD, sock_path, 0o600, 0) catch {};
std.posix.fchmodat(posix.AT.FDCWD, sock_path, 0o600, 0) catch |err| {
log.warn("failed to chmod notify socket: {}", .{err});
};

// Make accept non-blocking so the loop can observe stop requests.
const flags = posix.fcntl(fd, posix.F.GETFL, 0) catch null;
if (flags) |f| {
var o_flags: posix.O = @bitCast(@as(u32, @intCast(f)));
o_flags.NONBLOCK = true;
_ = posix.fcntl(fd, posix.F.SETFL, @as(u32, @bitCast(o_flags))) catch {};
if (posix.fcntl(fd, posix.F.SETFL, @as(u32, @bitCast(o_flags)))) |_| {} else |err| {
log.warn("failed to set socket non-blocking: {}", .{err});
}
}

while (!ctx.stop.load(.seq_cst)) {
Expand All @@ -128,7 +134,9 @@ pub fn startNotifyThread(
if (conn_flags) |f| {
var o_flags: posix.O = @bitCast(@as(u32, @intCast(f)));
o_flags.NONBLOCK = true;
_ = posix.fcntl(conn_fd, posix.F.SETFL, @as(u32, @bitCast(o_flags))) catch {};
if (posix.fcntl(conn_fd, posix.F.SETFL, @as(u32, @bitCast(o_flags)))) |_| {} else |err| {
log.warn("failed to set connection non-blocking: {}", .{err});
}
}

var buffer = std.ArrayList(u8){};
Expand All @@ -148,7 +156,9 @@ pub fn startNotifyThread(
if (buffer.items.len == 0) continue;

if (parseNotification(buffer.items)) |note| {
ctx.queue.push(ctx.allocator, note) catch {};
ctx.queue.push(ctx.allocator, note) catch |err| {
log.warn("failed to queue notification for session {d}: {}", .{ note.session, err });
};
}
}
}
Expand Down
15 changes: 12 additions & 3 deletions src/session/state.zig
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ pub const SessionState = struct {

log.debug("spawned session {d}", .{self.id});

self.processOutput() catch {};
self.processOutput() catch |err| {
log.warn("session {d}: initial output processing failed: {}", .{ self.id, err });
};

self.seedCwd(working_dir) catch |err| {
log.warn("failed to record cwd for session {d}: {}", .{ self.id, err });
Expand All @@ -224,12 +226,18 @@ pub const SessionState = struct {
self.cwd_basename = null;
}

// Clean up process watcher. We mark the context as orphaned so the callback frees it
// when it eventually fires. If the callback never fires (process still running at
// shutdown), the WaitContext (~40 bytes) leaks. This is acceptable because:
// (1) it only happens at app shutdown when the OS reclaims all memory anyway,
// (2) proper cancellation via xev's cancel API would require threading the loop
// through to deinit, adding complexity for negligible benefit,
// (3) there's no race condition since xev is single-threaded.
if (self.process_watcher) |*watcher| {
watcher.deinit();
self.process_watcher = null;
}
if (self.process_wait_ctx) |ctx| {
// Mark as orphaned; the callback will free the context when it fires.
ctx.orphaned = true;
self.process_wait_ctx = null;
}
Expand Down Expand Up @@ -342,12 +350,13 @@ pub const SessionState = struct {
fn resetForRespawn(self: *SessionState) void {
self.clearSelection();
self.pending_write.clearAndFree(self.allocator);
// Clean up process watcher. The orphaned flag ensures the callback (which will fire
// when the old process exits) frees the context without affecting the new session state.
if (self.process_watcher) |*watcher| {
watcher.deinit();
self.process_watcher = null;
}
if (self.process_wait_ctx) |ctx| {
// Mark as orphaned; the callback will free the context when it fires.
ctx.orphaned = true;
self.process_wait_ctx = null;
}
Expand Down
10 changes: 6 additions & 4 deletions src/shell.zig
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,13 @@ pub const Shell = struct {
setDefaultEnv("LANG", DEFAULT_LANG);
setDefaultEnv("TERM_PROGRAM", DEFAULT_TERM_PROGRAM);

// Change to specified directory or home directory before spawning shell
if (working_dir) |dir| {
// Change to specified directory or home directory before spawning shell.
// Errors are intentionally ignored: we're in a forked child process where
// logging is impractical, and chdir failure is non-fatal (shell starts in
// the parent's cwd instead). Try working_dir first, fall back to HOME.
const target_dir = working_dir orelse posix.getenv("HOME");
if (target_dir) |dir| {
posix.chdir(dir) catch {};
} else if (posix.getenv("HOME")) |home| {
posix.chdir(home) catch {};
}

posix.dup2(pty_instance.slave, posix.STDIN_FILENO) catch std.c._exit(1);
Expand Down
10 changes: 8 additions & 2 deletions src/ui/components/confirm_dialog.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const UiComponent = @import("../component.zig").UiComponent;
const dpi = @import("../scale.zig");
const font_cache = @import("../../font_cache.zig");

const log = std.log.scoped(.confirm_dialog);

pub const ConfirmDialogComponent = struct {
allocator: std.mem.Allocator,
font_generation: u64 = 0,
Expand Down Expand Up @@ -123,7 +125,9 @@ pub const ConfirmDialogComponent = struct {
const is_confirm = key == c.SDLK_RETURN or key == c.SDLK_RETURN2 or key == c.SDLK_KP_ENTER;
if (is_confirm) {
if (self.on_confirm) |action| {
actions.append(action) catch {};
actions.append(action) catch |err| {
log.warn("failed to queue dialog confirmation: {}", .{err});
};
}
self.visible = false;
self.escape_pressed = false;
Expand All @@ -144,7 +148,9 @@ pub const ConfirmDialogComponent = struct {
const buttons = self.buttonRects(modal, host.ui_scale);
if (geom.containsPoint(buttons.confirm, mouse_x, mouse_y)) {
if (self.on_confirm) |action| {
actions.append(action) catch {};
actions.append(action) catch |err| {
log.warn("failed to queue dialog confirmation: {}", .{err});
};
}
self.visible = false;
return true;
Expand Down
6 changes: 5 additions & 1 deletion src/ui/components/escape_hold.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const HoldGesture = @import("../gestures/hold.zig").HoldGesture;
const dpi = @import("../scale.zig");
const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard;

const log = std.log.scoped(.escape_hold);

pub const EscapeHoldComponent = struct {
allocator: std.mem.Allocator,
gesture: HoldGesture = .{},
Expand Down Expand Up @@ -76,7 +78,9 @@ pub const EscapeHoldComponent = struct {
if (!self.gesture.active) return;
if (self.gesture.isComplete(host.now_ms) and !self.gesture.consumed) {
self.gesture.consumed = true;
actions.append(.RequestCollapseFocused) catch {};
actions.append(.RequestCollapseFocused) catch |err| {
log.warn("failed to queue collapse action: {}", .{err});
};
}

if (self.gesture.consumed) {
Expand Down
6 changes: 5 additions & 1 deletion src/ui/components/global_shortcuts.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const c = @import("../../c.zig");
const types = @import("../types.zig");
const UiComponent = @import("../component.zig").UiComponent;

const log = std.log.scoped(.global_shortcuts);

pub const GlobalShortcutsComponent = struct {
allocator: std.mem.Allocator,

Expand Down Expand Up @@ -34,7 +36,9 @@ pub const GlobalShortcutsComponent = struct {
const has_blocking_mod = (mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT)) != 0;

if (key == c.SDLK_COMMA and has_gui and !has_blocking_mod) {
actions.append(.OpenConfig) catch {};
actions.append(.OpenConfig) catch |err| {
log.warn("failed to queue open config action: {}", .{err});
};
return true;
}

Expand Down
6 changes: 5 additions & 1 deletion src/ui/components/hotkey_indicator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const UiComponent = @import("../component.zig").UiComponent;
const dpi = @import("../scale.zig");
const FirstFrameGuard = @import("../first_frame_guard.zig").FirstFrameGuard;

const log = std.log.scoped(.hotkey_indicator);

pub const HotkeyIndicatorComponent = struct {
allocator: std.mem.Allocator,
font: *font_mod.Font,
Expand Down Expand Up @@ -187,7 +189,9 @@ pub const HotkeyIndicatorComponent = struct {
const end = @min(idx + seq_len, label_slice.len);
const cp_slice = label_slice[idx..end];
const codepoint = std.unicode.utf8Decode(cp_slice) catch 0xFFFD;
self.font.renderGlyph(codepoint, x, y, self.font.cell_width, self.font.cell_height, text_color) catch {};
self.font.renderGlyph(codepoint, x, y, self.font.cell_width, self.font.cell_height, text_color) catch |err| {
log.debug("failed to render glyph U+{X:0>4}: {}", .{ codepoint, err });
};
x += self.font.cell_width;
idx = end;
}
Expand Down
10 changes: 8 additions & 2 deletions src/ui/components/quit_confirm.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const dpi = @import("../scale.zig");
const button = @import("button.zig");
const font_cache = @import("../../font_cache.zig");

const log = std.log.scoped(.quit_confirm);

pub const QuitConfirmComponent = struct {
allocator: std.mem.Allocator,
font_generation: u64 = 0,
Expand Down Expand Up @@ -98,7 +100,9 @@ pub const QuitConfirmComponent = struct {
const mod = event.key.mod;
const is_confirm = key == c.SDLK_RETURN or key == c.SDLK_RETURN2 or key == c.SDLK_KP_ENTER or (key == c.SDLK_Q and (mod & c.SDL_KMOD_GUI) != 0);
if (is_confirm) {
actions.append(.ConfirmQuit) catch {};
actions.append(.ConfirmQuit) catch |err| {
log.warn("failed to queue quit confirmation: {}", .{err});
};
self.visible = false;
self.escape_pressed = false;
return true;
Expand All @@ -117,7 +121,9 @@ pub const QuitConfirmComponent = struct {
const modal = self.modalRect(host);
const buttons = self.buttonRects(modal, host.ui_scale);
if (geom.containsPoint(buttons.quit, mouse_x, mouse_y)) {
actions.append(.ConfirmQuit) catch {};
actions.append(.ConfirmQuit) catch |err| {
log.warn("failed to queue quit confirmation: {}", .{err});
};
self.visible = false;
return true;
}
Expand Down
Loading