From 2bfee18bbd44fcf99c6bf0b471d6a4f299ec1ecf Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 23 Jun 2026 00:49:06 +0000 Subject: [PATCH 1/5] feat: mark sessions with unread output in the boo ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session daemon already feeds every window's output through ghostty-vt. It now tracks a per-session `unread` flag: output produced while no client is attached marks the session unread, and attaching (viewing it) clears it. This is a terminal-multiplexer-native "activity since you last looked" signal that doubles as a coding-agent "your turn" cue without any agent-specific detection or bell heuristics. - daemon: set `unread` on detached output, clear on attach, report it in the `info` reply. Backward compatible: a tab separates the flag from the tab-free title, and an older daemon's shorter reply parses with unread=false. - ls --json: add `"unread"`. `idle_ms` is already present, so idleness is left for consumers to threshold rather than duplicated. - ui: the sidebar marks unread sessions with a `●`, bold yellow once the session's output has settled (idle: waiting on you) and dim while still producing output. Unread takes priority over the `*` attached-elsewhere marker. - help: document the markers and the new JSON field. Generated by Coder Agent on behalf of @kylecarbs. --- src/daemon.zig | 14 ++++++- src/help.zig | 13 ++++++- src/main.zig | 26 ++++++++++--- src/ui.zig | 82 +++++++++++++++++++++++++++++++++++++++-- test/integration.zig | 88 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 12 deletions(-) diff --git a/src/daemon.zig b/src/daemon.zig index bd4e72c..4451b43 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -78,6 +78,10 @@ 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, + sig_read: posix.fd_t = -1, quitting: bool = false, @@ -262,6 +266,9 @@ pub const Daemon = struct { } } conn.attached = true; + // Attaching is viewing, so the session's output is no + // longer unseen. + self.unread = false; self.key_parser = .{}; self.resizeWindow(size.rows, size.cols); self.updatePassthrough(); @@ -403,11 +410,12 @@ pub const Daemon = struct { 0; 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", .{ self.opts.name, if (attached) "Attached" else "Detached", idle, out_idle, + @intFromBool(self.unread), }); // Window title last; sanitized, so it cannot contain the // tabs that separate the fields. @@ -517,6 +525,10 @@ 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. + if (self.attachedConn() == null) self.unread = true; const conn = (if (win.passthrough) self.attachedConn() else null) orelse { // Not passed through: the window answers queries itself. diff --git a/src/help.zig b/src/help.zig index 79e50fe..4e2fd0a 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): + \\ ● unread output you have not viewed. Bold yellow once the + \\ session's output settles (it is waiting on you), dim + \\ while it is still producing output. 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,8 @@ pub const commands = [_]Entry{ \\ \\flags: \\ --json emit a JSON array: - \\ [{"name","attached","idle_ms","title"}] + \\ [{"name","attached","idle_ms","unread","title"}] + \\ ("unread" flags output you have not viewed) \\ , }, @@ -358,7 +366,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", + \\ "title"}] \\ boo peek --json {"session","title","rows","cols", \\ "cursor":{"row","col"},"screen"} \\ diff --git a/src/main.zig b/src/main.zig index 6ce36c5..ab376f0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -159,12 +159,15 @@ 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 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, /// Window title; slices into `text`. title: []const u8, }; @@ -188,12 +191,23 @@ 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 either `unread \t title` (current daemon) or just + // `title` (a daemon predating the field). The title is tab-free, so a + // tab in the remainder unambiguously separates the unread flag from + // the title; without one it is all title and unread defaults false. + const rest = it.rest(); + var unread = false; + var title = rest; + if (std.mem.indexOfScalar(u8, rest, '\t')) |tab| { + unread = std.mem.eql(u8, rest[0..tab], "1"); + title = rest[tab + 1 ..]; + } return .{ .text = result.text, .attached = attached, .idle_ms = idle_ms, .out_idle_ms = out_idle_ms, + .unread = unread, .title = title, }; } @@ -437,9 +451,10 @@ 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\":{},\"title\":", .{ entry.info.attached, entry.info.idle_ms, + entry.info.unread, }); defer alloc.free(tail); try out.appendSlice(alloc, tail); @@ -688,8 +703,9 @@ fn cmdPeek(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { try stdoutWrite(out.items); } -/// How long output must stay quiet for `wait --idle` to fire. -const idle_settle_ms: i64 = 2000; +/// How long output must stay quiet for `wait --idle` to fire, and for a +/// session to count as idle in the ui. +pub const idle_settle_ms: i64 = 2000; fn cmdWait(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { var name_arg: ?[]const u8 = null; diff --git a/src/ui.zig b/src/ui.zig index a5f3153..a49ca70 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -841,6 +841,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, + /// The session's output has settled (quiet for idle_settle_ms). An + /// unread session that is idle is waiting on you; one still + /// producing output is working. + idle: bool = false, /// Owned by the list; control bytes are stripped by the daemon /// but the title may contain any UTF-8 text. title: []u8, @@ -859,6 +866,11 @@ fn freeEntries(alloc: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { const sgr_reset = "\x1b[0m"; const style_selected = "\x1b[7m"; const style_dim = "\x1b[2m"; +/// Bold yellow: unread output that has settled, the "your turn" marker +/// on a session row. +const style_attention = "\x1b[1;33m"; +/// The one-column glyph marking a session with unread output. +const unread_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 @@ -985,10 +997,25 @@ pub fn appendSessionRow( 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); + // The leading status column, always exactly one display cell: + // ● unread output you have not viewed. Bold yellow once the + // session has gone idle (its output settled, so it is waiting + // on you), dim while it is still producing output. + // * attached by another client. + // (space) nothing to flag. The selected session is attached by + // this ui itself, which is not worth a '*'. + // Unread takes priority: a session with output you have not seen + // matters more than who is holding it. + if (entry.unread) { + try out.appendSlice(alloc, if (entry.idle) style_attention else style_dim); + try out.appendSlice(alloc, unread_marker); + try out.appendSlice(alloc, sgr_reset); + // Restore the row highlight the marker's reset cleared. + if (selected) try out.appendSlice(alloc, style_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. @@ -2120,6 +2147,8 @@ const Ui = struct { .name = try self.alloc.dupe(u8, name), .attached = info.attached, .idle_ms = info.idle_ms, + .unread = info.unread, + .idle = info.out_idle_ms >= main.idle_settle_ms, .title = try self.alloc.dupe(u8, info.title), }); } @@ -4201,6 +4230,51 @@ test "sidebar session row is exactly the requested width" { try std.testing.expect(std.mem.indexOf(u8, out.items, ">") == null); } +test "sidebar marks a session with unread output" { + 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 unread+idle at once, to prove unread wins + // and that idle selects the bold-yellow "your turn" style. + const entry: Entry = .{ + .name = &name_buf, + .attached = true, + .idle_ms = 0, + .unread = true, + .idle = true, + .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 ++ unread_marker ++ sgr_reset ++ + "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset; + try std.testing.expectEqualStrings(expected, out.items); + // Unread takes priority over the attached-elsewhere '*' marker. + try std.testing.expect(std.mem.indexOfScalar(u8, out.items, '*') == null); + + // Unread but still producing output (not idle) is the dim marker. + out.clearRetainingCapacity(); + var working = entry; + working.idle = false; + try appendSessionRow(alloc, &out, working, 24, false); + const expected_working = style_dim ++ unread_marker ++ sgr_reset ++ + "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset; + try std.testing.expectEqualStrings(expected_working, out.items); + + // Selected: the marker's SGR reset must not drop the row highlight, + // so the inverse style is re-applied right after the marker. + out.clearRetainingCapacity(); + try appendSessionRow(alloc, &out, entry, 24, true); + const expected_sel = style_selected ++ style_attention ++ unread_marker ++ + sgr_reset ++ style_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/test/integration.zig b/test/integration.zig index 8553d59..13c2c1e 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -173,8 +173,54 @@ 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; + }; + } + } }; +/// 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; +} + const Deadline = struct { end: i64, @@ -1030,9 +1076,51 @@ 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); } } +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{25CF}"); +} + test "peek --json includes geometry, cursor, and screen content" { const alloc = std.testing.allocator; var h = try Harness.init(alloc); From 8376dbccb4c7b922e693feca83c3668986e27dcb Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 23 Jun 2026 01:00:13 +0000 Subject: [PATCH 2/5] feat: use a blue bullet for the unread sidebar marker The filled circle (U+25CF) was visually heavy and crowded the session name. Switch to a lighter bullet (U+2022) and color the settled "your turn" marker blue (bold) instead of yellow. The in-progress "working" state stays dim. --- src/help.zig | 2 +- src/ui.zig | 12 ++++++------ test/integration.zig | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/help.zig b/src/help.zig index 4e2fd0a..f7235f7 100644 --- a/src/help.zig +++ b/src/help.zig @@ -126,7 +126,7 @@ pub const commands = [_]Entry{ \\terminal state. \\ \\sidebar markers (left of the name): - \\ ● unread output you have not viewed. Bold yellow once the + \\ • unread output you have not viewed. Bold blue once the \\ session's output settles (it is waiting on you), dim \\ while it is still producing output. Clears when you focus \\ the session. diff --git a/src/ui.zig b/src/ui.zig index a49ca70..94a3907 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -866,11 +866,11 @@ fn freeEntries(alloc: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { const sgr_reset = "\x1b[0m"; const style_selected = "\x1b[7m"; const style_dim = "\x1b[2m"; -/// Bold yellow: unread output that has settled, the "your turn" marker +/// Bold blue: unread output that has settled, the "your turn" marker /// on a session row. -const style_attention = "\x1b[1;33m"; +const style_attention = "\x1b[1;34m"; /// The one-column glyph marking a session with unread output. -const unread_marker = "\u{25CF}"; // ● +const unread_marker = "\u{2022}"; // • /// Display width in terminal columns of one codepoint: 0 for /// combining and other zero-width marks, 2 for East Asian wide and @@ -998,7 +998,7 @@ pub fn appendSessionRow( if (selected) try out.appendSlice(alloc, style_selected); // The leading status column, always exactly one display cell: - // ● unread output you have not viewed. Bold yellow once the + // • unread output you have not viewed. Bold blue once the // session has gone idle (its output settled, so it is waiting // on you), dim while it is still producing output. // * attached by another client. @@ -4238,7 +4238,7 @@ test "sidebar marks a session with unread output" { var name_buf: [8]u8 = "work1234".*; var title_buf: [0]u8 = .{}; // Attached elsewhere AND unread+idle at once, to prove unread wins - // and that idle selects the bold-yellow "your turn" style. + // and that idle selects the bold-blue "your turn" style. const entry: Entry = .{ .name = &name_buf, .attached = true, @@ -4248,7 +4248,7 @@ test "sidebar marks a session with unread output" { .title = &title_buf, }; - // The marker is one display cell (the ● glyph), so the row is still + // 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 ++ unread_marker ++ sgr_reset ++ diff --git a/test/integration.zig b/test/integration.zig index 13c2c1e..dd780e1 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -1116,9 +1116,9 @@ test "ui: an unread session is marked in the sidebar" { 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 + // 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{25CF}"); + try ui.waitFor("\u{2022}"); } test "peek --json includes geometry, cursor, and screen content" { From e92a0349d59053ccabda4a91f58434b664413398 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 23 Jun 2026 01:11:20 +0000 Subject: [PATCH 3/5] feat(src/ui.zig): poll the sidebar every 250ms Background session rows (title, unread, idle) are refreshed by polling; only the focused session has a live socket. At a 1s cadence those rows looked frozen between ticks when the focused session was idle. Drop the interval to 250ms so background rows track changes without waiting on the focused session's own title churn to force a re-poll. --- src/ui.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ui.zig b/src/ui.zig index 94a3907..7f3a7c7 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 From 6257f4d858073a4d2aa4ac4fe3cf29791b88f50e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 23 Jun 2026 02:00:14 +0000 Subject: [PATCH 4/5] feat: mark a terminal bell as "your turn" in the boo ui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the output-settle heuristic for the "your turn" marker with an explicit signal: a terminal bell (BEL) that rings while no client is attached. Output that settles after a pause (a sleep in a script, a slow compile) no longer looks finished; only an intentional bell does. The window parser already distinguishes a real BEL from the BEL that terminates an OSC string, so setting a title never trips it. The daemon records the time of the last bell rung while detached and clears it on attach. The info reply exposes it as an age (bell_idle_ms, -1 for none) computed against the same now as out_idle, so the ui can combine the two. ls --json gains bell_idle_ms. The sidebar now shows a bold-blue ● (your turn) when a bell rang while you were away, a dim • when there is unread output but no bell, '*' when attached elsewhere, and a space otherwise. --- src/daemon.zig | 33 +++++++++++++++- src/help.zig | 16 ++++---- src/main.zig | 37 ++++++++++++------ src/ui.zig | 66 +++++++++++++++++--------------- src/window.zig | 13 ++++++- test/integration.zig | 89 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 52 deletions(-) diff --git a/src/daemon.zig b/src/daemon.zig index 4451b43..957e8e5 100644 --- a/src/daemon.zig +++ b/src/daemon.zig @@ -82,6 +82,13 @@ pub const Daemon = struct { /// 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, @@ -269,6 +276,7 @@ pub const Daemon = struct { // 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(); @@ -408,14 +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{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. @@ -528,11 +544,13 @@ pub const Daemon = struct { // Output produced while nothing is attached marks the session // unread, so the ui can flag activity since you last looked. // Attaching clears it. - if (self.attachedConn() == null) self.unread = true; + 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; }; @@ -557,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); @@ -570,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 f7235f7..dab99b6 100644 --- a/src/help.zig +++ b/src/help.zig @@ -126,10 +126,10 @@ pub const commands = [_]Entry{ \\terminal state. \\ \\sidebar markers (left of the name): - \\ • unread output you have not viewed. Bold blue once the - \\ session's output settles (it is waiting on you), dim - \\ while it is still producing output. Clears when you focus - \\ the session. + \\ ● 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: @@ -188,8 +188,10 @@ pub const commands = [_]Entry{ \\ \\flags: \\ --json emit a JSON array: - \\ [{"name","attached","idle_ms","unread","title"}] - \\ ("unread" flags output you have not viewed) + \\ [{"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) \\ , }, @@ -367,7 +369,7 @@ pub const topics = [_]Entry{ \\ \\machine-readable output: \\ boo ls --json [{"name","attached","idle_ms","unread", - \\ "title"}] + \\ "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 ab376f0..ef74e7b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -159,7 +159,7 @@ fn resolveSession( pub const SessionInfo = struct { /// Full info payload: - /// name \t Attached|Detached \t idle_ms \t out_idle_ms \t unread \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, @@ -168,6 +168,10 @@ pub const SessionInfo = struct { /// 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, }; @@ -191,16 +195,24 @@ 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; - // The remainder is either `unread \t title` (current daemon) or just - // `title` (a daemon predating the field). The title is tab-free, so a - // tab in the remainder unambiguously separates the unread flag from - // the title; without one it is all title and unread defaults false. + // 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')) |tab| { - unread = std.mem.eql(u8, rest[0..tab], "1"); - title = rest[tab + 1 ..]; + 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, @@ -208,6 +220,7 @@ pub fn sessionInfo(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) .idle_ms = idle_ms, .out_idle_ms = out_idle_ms, .unread = unread, + .bell_idle_ms = bell_idle_ms, .title = title, }; } @@ -451,10 +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},\"unread\":{},\"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); @@ -703,9 +717,8 @@ fn cmdPeek(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { try stdoutWrite(out.items); } -/// How long output must stay quiet for `wait --idle` to fire, and for a -/// session to count as idle in the ui. -pub const idle_settle_ms: i64 = 2000; +/// How long output must stay quiet for `wait --idle` to fire. +const idle_settle_ms: i64 = 2000; fn cmdWait(alloc: std.mem.Allocator, args: []const [:0]const u8) !void { var name_arg: ?[]const u8 = null; diff --git a/src/ui.zig b/src/ui.zig index 7f3a7c7..5245cd5 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -848,10 +848,10 @@ pub const Entry = struct { /// 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, - /// The session's output has settled (quiet for idle_settle_ms). An - /// unread session that is idle is waiting on you; one still - /// producing output is working. - idle: 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, @@ -870,11 +870,13 @@ fn freeEntries(alloc: std.mem.Allocator, entries: *std.ArrayList(Entry)) void { const sgr_reset = "\x1b[0m"; const style_selected = "\x1b[7m"; const style_dim = "\x1b[2m"; -/// Bold blue: unread output that has settled, the "your turn" marker -/// on a session row. +/// Bold blue: the "your turn" marker, a bell that rang while you were +/// away. const style_attention = "\x1b[1;34m"; -/// The one-column glyph marking a session with unread output. +/// 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 @@ -1002,20 +1004,24 @@ pub fn appendSessionRow( if (selected) try out.appendSlice(alloc, style_selected); // The leading status column, always exactly one display cell: - // • unread output you have not viewed. Bold blue once the - // session has gone idle (its output settled, so it is waiting - // on you), dim while it is still producing output. + // ● 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 '*'. - // Unread takes priority: a session with output you have not seen - // matters more than who is holding it. - if (entry.unread) { - try out.appendSlice(alloc, if (entry.idle) style_attention else style_dim); - try out.appendSlice(alloc, unread_marker); + // 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_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_selected); } else { const marker: u8 = if (!selected and entry.attached) '*' else ' '; try out.append(alloc, marker); @@ -2152,7 +2158,7 @@ const Ui = struct { .attached = info.attached, .idle_ms = info.idle_ms, .unread = info.unread, - .idle = info.out_idle_ms >= main.idle_settle_ms, + .bell_idle_ms = info.bell_idle_ms, .title = try self.alloc.dupe(u8, info.title), }); } @@ -4234,47 +4240,47 @@ test "sidebar session row is exactly the requested width" { try std.testing.expect(std.mem.indexOf(u8, out.items, ">") == null); } -test "sidebar marks a session with unread output" { +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 unread+idle at once, to prove unread wins - // and that idle selects the bold-blue "your turn" style. + // 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, - .idle = true, + .bell_idle_ms = 0, .title = &title_buf, }; - // The marker is one display cell (the • glyph), so the row is still + // 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 ++ unread_marker ++ sgr_reset ++ + const expected = style_attention ++ attention_marker ++ sgr_reset ++ "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset; try std.testing.expectEqualStrings(expected, out.items); - // Unread takes priority over the attached-elsewhere '*' marker. + // The bell marker takes priority over the attached-elsewhere '*'. try std.testing.expect(std.mem.indexOfScalar(u8, out.items, '*') == null); - // Unread but still producing output (not idle) is the dim marker. + // Unread with no bell is the dim • marker. out.clearRetainingCapacity(); - var working = entry; - working.idle = false; - try appendSessionRow(alloc, &out, working, 24, false); - const expected_working = style_dim ++ unread_marker ++ sgr_reset ++ + 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_working, out.items); + try std.testing.expectEqualStrings(expected_unread, out.items); // Selected: the marker's SGR reset must not drop the row highlight, // so the inverse style is re-applied right after the marker. out.clearRetainingCapacity(); try appendSessionRow(alloc, &out, entry, 24, true); - const expected_sel = style_selected ++ style_attention ++ unread_marker ++ + const expected_sel = style_selected ++ style_attention ++ attention_marker ++ sgr_reset ++ style_selected ++ "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset; try std.testing.expectEqualStrings(expected_sel, out.items); } 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 dd780e1..113a56b 100644 --- a/test/integration.zig +++ b/test/integration.zig @@ -192,6 +192,26 @@ const Harness = struct { }; } } + + /// 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 @@ -221,6 +241,33 @@ fn jsonUnread(alloc: std.mem.Allocator, json: []const u8, session: []const u8) ? 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, @@ -1078,6 +1125,8 @@ test "ls emits machine-readable JSON" { 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); } } @@ -1121,6 +1170,46 @@ test "ui: an unread session is marked in the sidebar" { 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); From 71f46227e02b9b8e0fa535a3bd74c733d2dceeb1 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 23 Jun 2026 02:14:45 +0000 Subject: [PATCH 5/5] fix(src/ui.zig): use a dark background for the selected sidebar row Reverse video inverted the selected row to a harsh bright block that washed out the dim title row beneath the name. Give the selected row a dark gray background (256-color 238) with normal-intensity text instead, so the subtitle stays readable. Reverse video is kept for the in-progress mouse text selection in the viewport, where it is the conventional look. --- src/ui.zig | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/ui.zig b/src/ui.zig index 5245cd5..81bb444 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -868,7 +868,13 @@ 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. @@ -991,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, @@ -1001,7 +1007,7 @@ pub fn appendSessionRow( selected: bool, ) !void { if (width == 0) return; - if (selected) try out.appendSlice(alloc, style_selected); + 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. @@ -1016,12 +1022,12 @@ pub fn appendSessionRow( 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_selected); + 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_selected); + if (selected) try out.appendSlice(alloc, style_row_selected); } else { const marker: u8 = if (!selected and entry.attached) '*' else ' '; try out.append(alloc, marker); @@ -1048,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) { @@ -4232,11 +4238,11 @@ 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); } @@ -4277,11 +4283,11 @@ test "sidebar marks your-turn and unread sessions" { try std.testing.expectEqualStrings(expected_unread, out.items); // Selected: the marker's SGR reset must not drop the row highlight, - // so the inverse style is re-applied right after the marker. + // so the background style is re-applied right after the marker. out.clearRetainingCapacity(); try appendSessionRow(alloc, &out, entry, 24, true); - const expected_sel = style_selected ++ style_attention ++ attention_marker ++ - sgr_reset ++ style_selected ++ "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset; + 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); }