diff --git a/src/daemon.zig b/src/daemon.zig index bd4e72c..957e8e5 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -78,6 +78,17 @@ pub const Daemon = struct { /// or client input; reported as session idle time. last_activity_ms: i64 = 0, + /// Output arrived while no client was attached: the session has + /// activity you have not seen. The ui flags it; attaching clears it. + unread: bool = false, + + /// Wall-clock time (milliseconds) of the last bell that rang while + /// no client was attached, or 0 for none since you last looked. A + /// bell is an explicit "your turn" request; the info reply reports + /// it as an age so the ui can combine it with output idle time. + /// Attaching clears it. + last_bell_ms: i64 = 0, + sig_read: posix.fd_t = -1, quitting: bool = false, @@ -262,6 +273,10 @@ pub const Daemon = struct { } } conn.attached = true; + // Attaching is viewing, so the session's output is no + // longer unseen. + self.unread = false; + self.last_bell_ms = 0; self.key_parser = .{}; self.resizeWindow(size.rows, size.cols); self.updatePassthrough(); @@ -401,13 +416,22 @@ pub const Daemon = struct { @max(0, now - w.last_output_ms) else 0; + // Age of the last bell that rang while you were away, or -1 + // for none. Reported against the same `now` as out_idle so + // the ui can compare the two. + const bell_idle: i64 = if (self.last_bell_ms != 0) + @max(0, now - self.last_bell_ms) + else + -1; var out: std.ArrayList(u8) = .empty; defer out.deinit(self.alloc); - try out.print(self.alloc, "{s}\t{s}\t{d}\t{d}\t", .{ + try out.print(self.alloc, "{s}\t{s}\t{d}\t{d}\t{d}\t{d}\t", .{ self.opts.name, if (attached) "Attached" else "Detached", idle, out_idle, + @intFromBool(self.unread), + bell_idle, }); // Window title last; sanitized, so it cannot contain the // tabs that separate the fields. @@ -517,10 +541,16 @@ pub const Daemon = struct { const now = std.time.milliTimestamp(); win.last_output_ms = now; self.last_activity_ms = now; + // Output produced while nothing is attached marks the session + // unread, so the ui can flag activity since you last looked. + // Attaching clears it. + const detached = self.attachedConn() == null; + if (detached) self.unread = true; const conn = (if (win.passthrough) self.attachedConn() else null) orelse { // Not passed through: the window answers queries itself. win.feed(chunk); + self.noteBell(win, detached, now); return; }; @@ -545,6 +575,7 @@ pub const Daemon = struct { const split = result.discard_start orelse chunk.len; win.feed(chunk[0..split]); if (split < chunk.len) win.feedDiscarded(chunk[split..]); + self.noteBell(win, detached, now); const filtered = writer.buffered(); if (filtered.len > 0) conn.send(.output, filtered); @@ -558,6 +589,16 @@ pub const Daemon = struct { } } + /// Consume the window's bell latch. A bell that rang while no client + /// was attached records the time as an explicit "your turn" signal; + /// a bell seen while attached already reached the client's terminal, + /// so it is only cleared. + fn noteBell(self: *Daemon, win: *Window, detached: bool, now: i64) void { + if (!win.bell) return; + win.bell = false; + if (detached) self.last_bell_ms = now; + } + /// Remove closed conns. Runs after every poll dispatch so /// iteration above never sees mutation. fn sweep(self: *Daemon) void { diff --git a/src/help.zig b/src/help.zig index 79e50fe..dab99b6 100644 --- a/src/help.zig +++ b/src/help.zig @@ -125,6 +125,13 @@ pub const commands = [_]Entry{ \\session runs in a viewport on the right, rendered live from \\terminal state. \\ + \\sidebar markers (left of the name): + \\ ● your turn: a bell rang while you were away (e.g. an agent + \\ finished its turn). Bold blue. Clears when you focus it. + \\ • unread output you have not viewed, dim. Clears when you + \\ focus the session. + \\ * attached by another client + \\ \\mouse: \\ click a session focus it (steals politely, like attach) \\ click its 'x' kill it (asks for confirmation) @@ -181,7 +188,10 @@ pub const commands = [_]Entry{ \\ \\flags: \\ --json emit a JSON array: - \\ [{"name","attached","idle_ms","title"}] + \\ [{"name","attached","idle_ms","unread", + \\ "bell_idle_ms","title"}] + \\ ("unread" flags unseen output; "bell_idle_ms" is the + \\ age of a bell rung while away, -1 if none) \\ , }, @@ -358,7 +368,8 @@ pub const topics = [_]Entry{ \\ control keys; stdin mode is binary safe. \\ \\machine-readable output: - \\ boo ls --json [{"name","attached","idle_ms","title"}] + \\ boo ls --json [{"name","attached","idle_ms","unread", + \\ "bell_idle_ms","title"}] \\ boo peek --json {"session","title","rows","cols", \\ "cursor":{"row","col"},"screen"} \\ diff --git a/src/main.zig b/src/main.zig index 6ce36c5..ef74e7b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -159,12 +159,19 @@ fn resolveSession( pub const SessionInfo = struct { /// Full info payload: - /// name \t Attached|Detached \t idle_ms \t out_idle_ms \t title. + /// name \t Attached|Detached \t idle_ms \t out_idle_ms \t unread \t bell_idle_ms \t title. text: []u8, attached: bool, idle_ms: i64, /// Time since the window last produced output; drives wait --idle. out_idle_ms: i64, + /// Output arrived while no client was attached. Defaults false + /// against an older daemon whose info reply predates the field. + unread: bool, + /// Milliseconds since the last bell that rang while you were away, + /// or -1 for none. A bell is an explicit "your turn" request. + /// Defaults -1 against a daemon predating the field. + bell_idle_ms: i64, /// Window title; slices into `text`. title: []const u8, }; @@ -188,12 +195,32 @@ pub fn sessionInfo(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) return error.BadResponse; const out_idle_ms = std.fmt.parseInt(i64, it.next() orelse return error.BadResponse, 10) catch return error.BadResponse; - const title = it.rest(); + // The remainder is `unread \t bell_idle_ms \t title` on a current + // daemon, `unread \t title` on one predating the bell field, or just + // `title` on one predating both. The title is tab-free, so leading + // fields peel off the front and the tab-free tail is the title; + // missing fields take their defaults (unread false, no bell). + const rest = it.rest(); + var unread = false; + var bell_idle_ms: i64 = -1; + var title = rest; + if (std.mem.indexOfScalar(u8, rest, '\t')) |t1| { + unread = std.mem.eql(u8, rest[0..t1], "1"); + const after = rest[t1 + 1 ..]; + if (std.mem.indexOfScalar(u8, after, '\t')) |t2| { + bell_idle_ms = std.fmt.parseInt(i64, after[0..t2], 10) catch -1; + title = after[t2 + 1 ..]; + } else { + title = after; + } + } return .{ .text = result.text, .attached = attached, .idle_ms = idle_ms, .out_idle_ms = out_idle_ms, + .unread = unread, + .bell_idle_ms = bell_idle_ms, .title = title, }; } @@ -437,9 +464,11 @@ fn cmdLs(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { if (i > 0) try out.append(alloc, ','); try out.appendSlice(alloc, "{\"name\":"); try appendJsonString(alloc, &out, entry.name); - const tail = try std.fmt.allocPrint(alloc, ",\"attached\":{},\"idle_ms\":{d},\"title\":", .{ + const tail = try std.fmt.allocPrint(alloc, ",\"attached\":{},\"idle_ms\":{d},\"unread\":{},\"bell_idle_ms\":{d},\"title\":", .{ entry.info.attached, entry.info.idle_ms, + entry.info.unread, + entry.info.bell_idle_ms, }); defer alloc.free(tail); try out.appendSlice(alloc, tail); diff --git a/src/ui.zig b/src/ui.zig index a5f3153..81bb444 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -32,8 +32,12 @@ const windowpkg = @import("window.zig"); const log = std.log.scoped(.ui); -/// Refresh cadence for the sidebar's session list. -const refresh_interval_ms: i64 = 1000; +/// Poll cadence for the sidebar's session list. Only the focused +/// session has a live socket; every other row's title, unread, and +/// idle state is refreshed by re-polling on this interval (plus an +/// immediate re-poll whenever the focused session changes its own +/// title), so this bounds how stale a background row can look. +const refresh_interval_ms: i64 = 250; /// Transient status messages stay visible this long. const message_ttl_ms: i64 = 4000; /// Render coalescing: at most one repaint per interval while output @@ -841,6 +845,13 @@ pub const Entry = struct { name: []u8, attached: bool, idle_ms: i64, + /// Output arrived while this session was not being viewed: it has + /// activity you have not seen. Shown as a marker on the name row. + unread: bool = false, + /// Milliseconds since the last bell that rang while you were away, + /// or -1 for none. A bell is an explicit "your turn" request, shown + /// more prominently than plain unread. + bell_idle_ms: i64 = -1, /// Owned by the list; control bytes are stripped by the daemon /// but the title may contain any UTF-8 text. title: []u8, @@ -857,8 +868,21 @@ fn freeEntries(alloc: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { // -- Sidebar rendering -------------------------------------------------------- const sgr_reset = "\x1b[0m"; +/// Reverse video, used to highlight an in-progress mouse text selection +/// over viewport content. const style_selected = "\x1b[7m"; +/// Dark gray background for the selected sidebar row. A gentle bar +/// rather than reverse video, whose bright inverted block washes out +/// the dim title row beneath the name. +const style_row_selected = "\x1b[48;5;238m"; const style_dim = "\x1b[2m"; +/// Bold blue: the "your turn" marker, a bell that rang while you were +/// away. +const style_attention = "\x1b[1;34m"; +/// Status glyph for a session whose output you have not viewed. +const unread_marker = "\u{2022}"; // • +/// Status glyph for "your turn": a bell rang while you were away. +const attention_marker = "\u{25CF}"; // ● /// Display width in terminal columns of one codepoint: 0 for /// combining and other zero-width marks, 2 for East Asian wide and @@ -973,7 +997,7 @@ fn appendClipped( /// One sidebar session name row: attached marker, name, and a kill /// target in the last column. Exactly `width` display columns plus -/// SGR codes; the inverse-video highlight alone marks the selected +/// SGR codes; the background highlight alone marks the selected /// session. pub fn appendSessionRow( alloc: std.mem.Allocator, @@ -983,12 +1007,31 @@ pub fn appendSessionRow( selected: bool, ) !void { if (width == 0) return; - if (selected) try out.appendSlice(alloc, style_selected); - - // '*': attached by another client. The selected session is - // attached by this UI itself, which is not worth a marker. - const marker: u8 = if (!selected and entry.attached) '*' else ' '; - try out.append(alloc, marker); + if (selected) try out.appendSlice(alloc, style_row_selected); + + // The leading status column, always exactly one display cell: + // ● your turn: a bell rang while you were away. Bold blue. + // • unread output you have not viewed. Dim. + // * attached by another client. + // (space) nothing to flag. The selected session is attached by + // this ui itself, which is not worth a '*'. + // A bell is an explicit request for you, so it outranks plain + // unread, which outranks who is holding the session. + if (entry.bell_idle_ms >= 0) { + try out.appendSlice(alloc, style_attention); + try out.appendSlice(alloc, attention_marker); + try out.appendSlice(alloc, sgr_reset); + // Restore the row highlight the marker's reset cleared. + if (selected) try out.appendSlice(alloc, style_row_selected); + } else if (entry.unread) { + try out.appendSlice(alloc, style_dim); + try out.appendSlice(alloc, unread_marker); + try out.appendSlice(alloc, sgr_reset); + if (selected) try out.appendSlice(alloc, style_row_selected); + } else { + const marker: u8 = if (!selected and entry.attached) '*' else ' '; + try out.append(alloc, marker); + } if (width >= 12) { // " x ": kill target in the last columns. @@ -1011,7 +1054,7 @@ pub fn appendSessionTitleRow( selected: bool, ) !void { if (width == 0) return; - if (selected) try out.appendSlice(alloc, style_selected); + if (selected) try out.appendSlice(alloc, style_row_selected); try out.appendSlice(alloc, style_dim); if (entry.title.len > 0 and width > 2) { @@ -2120,6 +2163,8 @@ const Ui = struct { .name = try self.alloc.dupe(u8, name), .attached = info.attached, .idle_ms = info.idle_ms, + .unread = info.unread, + .bell_idle_ms = info.bell_idle_ms, .title = try self.alloc.dupe(u8, info.title), }); } @@ -4193,14 +4238,59 @@ test "sidebar session row is exactly the requested width" { try std.testing.expect(std.mem.indexOf(u8, text, "work1234") != null); try std.testing.expect(std.mem.endsWith(u8, text, "x ")); - // Selected rows are wrapped in inverse video; the highlight is + // Selected rows are given a dark background bar; the highlight is // the only selection marker. out.clearRetainingCapacity(); try appendSessionRow(alloc, &out, entry, 24, true); - try std.testing.expect(std.mem.startsWith(u8, out.items, style_selected)); + try std.testing.expect(std.mem.startsWith(u8, out.items, style_row_selected)); try std.testing.expect(std.mem.indexOf(u8, out.items, ">") == null); } +test "sidebar marks your-turn and unread sessions" { + const alloc = std.testing.allocator; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(alloc); + + var name_buf: [8]u8 = "work1234".*; + var title_buf: [0]u8 = .{}; + // Attached elsewhere AND a bell rang while away at once, to prove + // the bell ("your turn") outranks both plain unread and the '*'. + const entry: Entry = .{ + .name = &name_buf, + .attached = true, + .idle_ms = 0, + .unread = true, + .bell_idle_ms = 0, + .title = &title_buf, + }; + + // The marker is one display cell (the ● glyph), so the row is still + // exactly 24 columns: 1 marker + 20 name + 3 " x ". + try appendSessionRow(alloc, &out, entry, 24, false); + const expected = style_attention ++ attention_marker ++ sgr_reset ++ + "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset; + try std.testing.expectEqualStrings(expected, out.items); + // The bell marker takes priority over the attached-elsewhere '*'. + try std.testing.expect(std.mem.indexOfScalar(u8, out.items, '*') == null); + + // Unread with no bell is the dim • marker. + out.clearRetainingCapacity(); + var unread_only = entry; + unread_only.bell_idle_ms = -1; + try appendSessionRow(alloc, &out, unread_only, 24, false); + const expected_unread = style_dim ++ unread_marker ++ sgr_reset ++ + "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset; + try std.testing.expectEqualStrings(expected_unread, out.items); + + // Selected: the marker's SGR reset must not drop the row highlight, + // so the background style is re-applied right after the marker. + out.clearRetainingCapacity(); + try appendSessionRow(alloc, &out, entry, 24, true); + const expected_sel = style_row_selected ++ style_attention ++ attention_marker ++ + sgr_reset ++ style_row_selected ++ "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset; + try std.testing.expectEqualStrings(expected_sel, out.items); +} + test "sidebar title row renders the title dim under the name" { const alloc = std.testing.allocator; var out: std.ArrayList(u8) = .empty; diff --git a/src/window.zig b/src/window.zig index 87dcb6c..5aa08e8 100644 --- a/src/window.zig +++ b/src/window.zig @@ -20,6 +20,13 @@ pub const Window = struct { child_pid: posix.pid_t, dead: bool = false, + /// The child rang the terminal bell since the daemon last serviced + /// this window. The parser sets this only on a real BEL, never on + /// the BEL that terminates an OSC string (so a title update cannot + /// trip it). The daemon reads and clears the latch each service + /// cycle. + bell: bool = false, + /// Fallback title: the command that was launched. command_title: []const u8, @@ -89,7 +96,7 @@ pub const Window = struct { var handler: Stream.Handler = .init(&self.term); handler.effects = .{ .write_pty = effectWritePty, - .bell = null, + .bell = effectBell, .color_scheme = null, .device_attributes = effectDeviceAttributes, .enquiry = null, @@ -145,6 +152,10 @@ pub const Window = struct { return @typeInfo(@typeInfo(Fn).pointer.child).@"fn".return_type.?; } + fn effectBell(handler: *Stream.Handler) void { + fromHandler(handler).bell = true; + } + fn effectDeviceAttributes(handler: *Stream.Handler) DeviceAttributes { _ = handler; return .{}; diff --git a/test/integration.zig b/test/integration.zig index 8553d59..113a56b 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -173,8 +173,101 @@ const Harness = struct { }; } } + + /// Poll `ls --json` until the session's "unread" flag equals want. + fn waitUnread(self: *Harness, session: []const u8, want: bool) !void { + var deadline = Deadline.init(default_timeout_ms); + while (true) { + const ls = try self.run(&.{ "ls", "--json" }); + defer self.alloc.free(ls.stdout); + defer self.alloc.free(ls.stderr); + if (ls.term == .Exited and ls.term.Exited == 0) { + if (jsonUnread(self.alloc, ls.stdout, session)) |u| { + if (u == want) return; + } + } + deadline.tick("unread flag never reached the wanted value") catch |err| { + std.debug.print("--- last ls --json ---\n{s}\n---\n", .{ls.stdout}); + return err; + }; + } + } + + /// Poll `ls --json` until the session's bell ("your turn") presence + /// matches `want`: a bell is present when bell_idle_ms is >= 0. + fn waitBell(self: *Harness, session: []const u8, want: bool) !void { + var deadline = Deadline.init(default_timeout_ms); + while (true) { + const ls = try self.run(&.{ "ls", "--json" }); + defer self.alloc.free(ls.stdout); + defer self.alloc.free(ls.stderr); + if (ls.term == .Exited and ls.term.Exited == 0) { + if (jsonBellIdle(self.alloc, ls.stdout, session)) |b| { + if ((b >= 0) == want) return; + } + } + deadline.tick("bell flag never reached the wanted value") catch |err| { + std.debug.print("--- last ls --json ---\n{s}\n---\n", .{ls.stdout}); + return err; + }; + } + } }; +/// The "unread" flag for `session` in a `boo ls --json` array, or null +/// when the session or field is absent or the JSON does not parse. +fn jsonUnread(alloc: std.mem.Allocator, json: []const u8, session: []const u8) ?bool { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, json, .{}) catch return null; + defer parsed.deinit(); + const arr = switch (parsed.value) { + .array => |a| a, + else => return null, + }; + for (arr.items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + const name = switch (obj.get("name") orelse continue) { + .string => |s| s, + else => continue, + }; + if (!std.mem.eql(u8, name, session)) continue; + return switch (obj.get("unread") orelse return null) { + .bool => |b| b, + else => null, + }; + } + return null; +} + +/// The "bell_idle_ms" value for `session` in a `boo ls --json` array, or +/// null when the session or field is absent or the JSON does not parse. +fn jsonBellIdle(alloc: std.mem.Allocator, json: []const u8, session: []const u8) ?i64 { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, json, .{}) catch return null; + defer parsed.deinit(); + const arr = switch (parsed.value) { + .array => |a| a, + else => return null, + }; + for (arr.items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + const name = switch (obj.get("name") orelse continue) { + .string => |s| s, + else => continue, + }; + if (!std.mem.eql(u8, name, session)) continue; + return switch (obj.get("bell_idle_ms") orelse return null) { + .integer => |n| n, + else => null, + }; + } + return null; +} + const Deadline = struct { end: i64, @@ -1030,9 +1123,93 @@ test "ls emits machine-readable JSON" { try std.testing.expectEqual(false, obj.get("attached").?.bool); try std.testing.expect(obj.get("idle_ms").?.integer >= 0); try std.testing.expectEqualStrings("cat", obj.get("title").?.string); + // A fresh cat session has produced no output, so nothing is unread. + try std.testing.expectEqual(false, obj.get("unread").?.bool); + // No bell has rung, so there is no "your turn". + try std.testing.expectEqual(@as(i64, -1), obj.get("bell_idle_ms").?.integer); } } +test "ls --json reports unread output, and attaching clears it" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // A detached session that prints (what a coding agent does when it + // finishes a turn) then idles so the daemon stays alive. + try h.startDetached("unr", &.{ "sh", "-c", "printf 'agent-needs-you\\n'; sleep 60" }); + + // Output produced while detached flips unread on. + try h.waitUnread("unr", true); + + // Attaching is viewing: the daemon clears unread on attach. + var client = try PtyClient.spawn(&h, &.{ "attach", "unr" }, 24, 80); + defer client.deinit(); + try client.waitFor("agent-needs-you"); + try h.waitUnread("unr", false); +} + +test "ui: an unread session is marked in the sidebar" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // Two sessions that print while detached, then idle. boo ui can + // auto-focus only one of them (clearing its unread), so the other + // is guaranteed to still be unread and carry the marker. + try h.startDetached("aaa", &.{ "sh", "-c", "printf 'hello\\n'; sleep 30" }); + try h.startDetached("bbb", &.{ "sh", "-c", "printf 'hello\\n'; sleep 30" }); + try h.waitUnread("aaa", true); + try h.waitUnread("bbb", true); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("aaa"); + // The sidebar marks the unfocused unread session with the • glyph + // once the periodic refresh picks up the daemon's unread flag. + try ui.waitFor("\u{2022}"); +} + +test "ls --json flags a bell as your turn, and attaching clears it" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // A detached session that rings the bell (what an agent does to get + // your attention) then idles so the daemon stays alive. + try h.startDetached("bel", &.{ "sh", "-c", "printf 'ding\\a\\n'; sleep 60" }); + + // The bell among detached output flips "your turn" on, and a bell is + // output, so unread is set too. + try h.waitBell("bel", true); + try h.waitUnread("bel", true); + + // Attaching is viewing: the daemon clears the bell on attach. + var client = try PtyClient.spawn(&h, &.{ "attach", "bel" }, 24, 80); + defer client.deinit(); + try client.waitFor("ding"); + try h.waitBell("bel", false); +} + +test "ui: a session that rang the bell is marked your turn" { + const alloc = std.testing.allocator; + var h = try Harness.init(alloc); + defer h.deinit(); + + // Two sessions ring the bell while detached, then idle. boo ui + // auto-focuses only one (clearing it), so the other keeps its ●. + try h.startDetached("aaa", &.{ "sh", "-c", "printf 'hi\\a\\n'; sleep 30" }); + try h.startDetached("bbb", &.{ "sh", "-c", "printf 'hi\\a\\n'; sleep 30" }); + try h.waitBell("aaa", true); + try h.waitBell("bbb", true); + + var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100); + defer ui.deinit(); + try ui.waitFor("aaa"); + // The unfocused belled session carries the ● your-turn glyph. + try ui.waitFor("\u{25CF}"); +} + test "peek --json includes geometry, cursor, and screen content" { const alloc = std.testing.allocator; var h = try Harness.init(alloc);