Skip to content

Commit ffed97e

Browse files
authored
feat: mark unread output and bell "your turn" in the boo ui (#86)
Add an unread marker (output while detached) and a bell-based "your turn" marker to the boo ui sidebar. A terminal bell rung while no client is attached is an explicit attention request; it is exposed as bell_idle_ms in the info reply and ls --json, and rendered as a bold-blue dot. Also softens the selected-row highlight and tightens the sidebar refresh to 250ms. Co-authored-by: nkthiebaut
1 parent 1837e78 commit ffed97e

6 files changed

Lines changed: 378 additions & 19 deletions

File tree

src/daemon.zig

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ pub const Daemon = struct {
7878
/// or client input; reported as session idle time.
7979
last_activity_ms: i64 = 0,
8080

81+
/// Output arrived while no client was attached: the session has
82+
/// activity you have not seen. The ui flags it; attaching clears it.
83+
unread: bool = false,
84+
85+
/// Wall-clock time (milliseconds) of the last bell that rang while
86+
/// no client was attached, or 0 for none since you last looked. A
87+
/// bell is an explicit "your turn" request; the info reply reports
88+
/// it as an age so the ui can combine it with output idle time.
89+
/// Attaching clears it.
90+
last_bell_ms: i64 = 0,
91+
8192
sig_read: posix.fd_t = -1,
8293
quitting: bool = false,
8394

@@ -262,6 +273,10 @@ pub const Daemon = struct {
262273
}
263274
}
264275
conn.attached = true;
276+
// Attaching is viewing, so the session's output is no
277+
// longer unseen.
278+
self.unread = false;
279+
self.last_bell_ms = 0;
265280
self.key_parser = .{};
266281
self.resizeWindow(size.rows, size.cols);
267282
self.updatePassthrough();
@@ -401,13 +416,22 @@ pub const Daemon = struct {
401416
@max(0, now - w.last_output_ms)
402417
else
403418
0;
419+
// Age of the last bell that rang while you were away, or -1
420+
// for none. Reported against the same `now` as out_idle so
421+
// the ui can compare the two.
422+
const bell_idle: i64 = if (self.last_bell_ms != 0)
423+
@max(0, now - self.last_bell_ms)
424+
else
425+
-1;
404426
var out: std.ArrayList(u8) = .empty;
405427
defer out.deinit(self.alloc);
406-
try out.print(self.alloc, "{s}\t{s}\t{d}\t{d}\t", .{
428+
try out.print(self.alloc, "{s}\t{s}\t{d}\t{d}\t{d}\t{d}\t", .{
407429
self.opts.name,
408430
if (attached) "Attached" else "Detached",
409431
idle,
410432
out_idle,
433+
@intFromBool(self.unread),
434+
bell_idle,
411435
});
412436
// Window title last; sanitized, so it cannot contain the
413437
// tabs that separate the fields.
@@ -517,10 +541,16 @@ pub const Daemon = struct {
517541
const now = std.time.milliTimestamp();
518542
win.last_output_ms = now;
519543
self.last_activity_ms = now;
544+
// Output produced while nothing is attached marks the session
545+
// unread, so the ui can flag activity since you last looked.
546+
// Attaching clears it.
547+
const detached = self.attachedConn() == null;
548+
if (detached) self.unread = true;
520549

521550
const conn = (if (win.passthrough) self.attachedConn() else null) orelse {
522551
// Not passed through: the window answers queries itself.
523552
win.feed(chunk);
553+
self.noteBell(win, detached, now);
524554
return;
525555
};
526556

@@ -545,6 +575,7 @@ pub const Daemon = struct {
545575
const split = result.discard_start orelse chunk.len;
546576
win.feed(chunk[0..split]);
547577
if (split < chunk.len) win.feedDiscarded(chunk[split..]);
578+
self.noteBell(win, detached, now);
548579

549580
const filtered = writer.buffered();
550581
if (filtered.len > 0) conn.send(.output, filtered);
@@ -558,6 +589,16 @@ pub const Daemon = struct {
558589
}
559590
}
560591

592+
/// Consume the window's bell latch. A bell that rang while no client
593+
/// was attached records the time as an explicit "your turn" signal;
594+
/// a bell seen while attached already reached the client's terminal,
595+
/// so it is only cleared.
596+
fn noteBell(self: *Daemon, win: *Window, detached: bool, now: i64) void {
597+
if (!win.bell) return;
598+
win.bell = false;
599+
if (detached) self.last_bell_ms = now;
600+
}
601+
561602
/// Remove closed conns. Runs after every poll dispatch so
562603
/// iteration above never sees mutation.
563604
fn sweep(self: *Daemon) void {

src/help.zig

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ pub const commands = [_]Entry{
125125
\\session runs in a viewport on the right, rendered live from
126126
\\terminal state.
127127
\\
128+
\\sidebar markers (left of the name):
129+
\\ ● your turn: a bell rang while you were away (e.g. an agent
130+
\\ finished its turn). Bold blue. Clears when you focus it.
131+
\\ • unread output you have not viewed, dim. Clears when you
132+
\\ focus the session.
133+
\\ * attached by another client
134+
\\
128135
\\mouse:
129136
\\ click a session focus it (steals politely, like attach)
130137
\\ click its 'x' kill it (asks for confirmation)
@@ -181,7 +188,10 @@ pub const commands = [_]Entry{
181188
\\
182189
\\flags:
183190
\\ --json emit a JSON array:
184-
\\ [{"name","attached","idle_ms","title"}]
191+
\\ [{"name","attached","idle_ms","unread",
192+
\\ "bell_idle_ms","title"}]
193+
\\ ("unread" flags unseen output; "bell_idle_ms" is the
194+
\\ age of a bell rung while away, -1 if none)
185195
\\
186196
,
187197
},
@@ -358,7 +368,8 @@ pub const topics = [_]Entry{
358368
\\ control keys; stdin mode is binary safe.
359369
\\
360370
\\machine-readable output:
361-
\\ boo ls --json [{"name","attached","idle_ms","title"}]
371+
\\ boo ls --json [{"name","attached","idle_ms","unread",
372+
\\ "bell_idle_ms","title"}]
362373
\\ boo peek --json {"session","title","rows","cols",
363374
\\ "cursor":{"row","col"},"screen"}
364375
\\

src/main.zig

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,19 @@ fn resolveSession(
159159

160160
pub const SessionInfo = struct {
161161
/// Full info payload:
162-
/// name \t Attached|Detached \t idle_ms \t out_idle_ms \t title.
162+
/// name \t Attached|Detached \t idle_ms \t out_idle_ms \t unread \t bell_idle_ms \t title.
163163
text: []u8,
164164
attached: bool,
165165
idle_ms: i64,
166166
/// Time since the window last produced output; drives wait --idle.
167167
out_idle_ms: i64,
168+
/// Output arrived while no client was attached. Defaults false
169+
/// against an older daemon whose info reply predates the field.
170+
unread: bool,
171+
/// Milliseconds since the last bell that rang while you were away,
172+
/// or -1 for none. A bell is an explicit "your turn" request.
173+
/// Defaults -1 against a daemon predating the field.
174+
bell_idle_ms: i64,
168175
/// Window title; slices into `text`.
169176
title: []const u8,
170177
};
@@ -188,12 +195,32 @@ pub fn sessionInfo(alloc: std.mem.Allocator, dir: []const u8, name: []const u8)
188195
return error.BadResponse;
189196
const out_idle_ms = std.fmt.parseInt(i64, it.next() orelse return error.BadResponse, 10) catch
190197
return error.BadResponse;
191-
const title = it.rest();
198+
// The remainder is `unread \t bell_idle_ms \t title` on a current
199+
// daemon, `unread \t title` on one predating the bell field, or just
200+
// `title` on one predating both. The title is tab-free, so leading
201+
// fields peel off the front and the tab-free tail is the title;
202+
// missing fields take their defaults (unread false, no bell).
203+
const rest = it.rest();
204+
var unread = false;
205+
var bell_idle_ms: i64 = -1;
206+
var title = rest;
207+
if (std.mem.indexOfScalar(u8, rest, '\t')) |t1| {
208+
unread = std.mem.eql(u8, rest[0..t1], "1");
209+
const after = rest[t1 + 1 ..];
210+
if (std.mem.indexOfScalar(u8, after, '\t')) |t2| {
211+
bell_idle_ms = std.fmt.parseInt(i64, after[0..t2], 10) catch -1;
212+
title = after[t2 + 1 ..];
213+
} else {
214+
title = after;
215+
}
216+
}
192217
return .{
193218
.text = result.text,
194219
.attached = attached,
195220
.idle_ms = idle_ms,
196221
.out_idle_ms = out_idle_ms,
222+
.unread = unread,
223+
.bell_idle_ms = bell_idle_ms,
197224
.title = title,
198225
};
199226
}
@@ -437,9 +464,11 @@ fn cmdLs(alloc: std.mem.Allocator, args: []const [:0]const u8) !void {
437464
if (i > 0) try out.append(alloc, ',');
438465
try out.appendSlice(alloc, "{\"name\":");
439466
try appendJsonString(alloc, &out, entry.name);
440-
const tail = try std.fmt.allocPrint(alloc, ",\"attached\":{},\"idle_ms\":{d},\"title\":", .{
467+
const tail = try std.fmt.allocPrint(alloc, ",\"attached\":{},\"idle_ms\":{d},\"unread\":{},\"bell_idle_ms\":{d},\"title\":", .{
441468
entry.info.attached,
442469
entry.info.idle_ms,
470+
entry.info.unread,
471+
entry.info.bell_idle_ms,
443472
});
444473
defer alloc.free(tail);
445474
try out.appendSlice(alloc, tail);

src/ui.zig

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ const windowpkg = @import("window.zig");
3232

3333
const log = std.log.scoped(.ui);
3434

35-
/// Refresh cadence for the sidebar's session list.
36-
const refresh_interval_ms: i64 = 1000;
35+
/// Poll cadence for the sidebar's session list. Only the focused
36+
/// session has a live socket; every other row's title, unread, and
37+
/// idle state is refreshed by re-polling on this interval (plus an
38+
/// immediate re-poll whenever the focused session changes its own
39+
/// title), so this bounds how stale a background row can look.
40+
const refresh_interval_ms: i64 = 250;
3741
/// Transient status messages stay visible this long.
3842
const message_ttl_ms: i64 = 4000;
3943
/// Render coalescing: at most one repaint per interval while output
@@ -841,6 +845,13 @@ pub const Entry = struct {
841845
name: []u8,
842846
attached: bool,
843847
idle_ms: i64,
848+
/// Output arrived while this session was not being viewed: it has
849+
/// activity you have not seen. Shown as a marker on the name row.
850+
unread: bool = false,
851+
/// Milliseconds since the last bell that rang while you were away,
852+
/// or -1 for none. A bell is an explicit "your turn" request, shown
853+
/// more prominently than plain unread.
854+
bell_idle_ms: i64 = -1,
844855
/// Owned by the list; control bytes are stripped by the daemon
845856
/// but the title may contain any UTF-8 text.
846857
title: []u8,
@@ -857,8 +868,21 @@ fn freeEntries(alloc: std.mem.Allocator, entries: *std.ArrayList(Entry)) void {
857868
// -- Sidebar rendering --------------------------------------------------------
858869

859870
const sgr_reset = "\x1b[0m";
871+
/// Reverse video, used to highlight an in-progress mouse text selection
872+
/// over viewport content.
860873
const style_selected = "\x1b[7m";
874+
/// Dark gray background for the selected sidebar row. A gentle bar
875+
/// rather than reverse video, whose bright inverted block washes out
876+
/// the dim title row beneath the name.
877+
const style_row_selected = "\x1b[48;5;238m";
861878
const style_dim = "\x1b[2m";
879+
/// Bold blue: the "your turn" marker, a bell that rang while you were
880+
/// away.
881+
const style_attention = "\x1b[1;34m";
882+
/// Status glyph for a session whose output you have not viewed.
883+
const unread_marker = "\u{2022}"; // •
884+
/// Status glyph for "your turn": a bell rang while you were away.
885+
const attention_marker = "\u{25CF}"; // ●
862886

863887
/// Display width in terminal columns of one codepoint: 0 for
864888
/// combining and other zero-width marks, 2 for East Asian wide and
@@ -973,7 +997,7 @@ fn appendClipped(
973997

974998
/// One sidebar session name row: attached marker, name, and a kill
975999
/// target in the last column. Exactly `width` display columns plus
976-
/// SGR codes; the inverse-video highlight alone marks the selected
1000+
/// SGR codes; the background highlight alone marks the selected
9771001
/// session.
9781002
pub fn appendSessionRow(
9791003
alloc: std.mem.Allocator,
@@ -983,12 +1007,31 @@ pub fn appendSessionRow(
9831007
selected: bool,
9841008
) !void {
9851009
if (width == 0) return;
986-
if (selected) try out.appendSlice(alloc, style_selected);
987-
988-
// '*': attached by another client. The selected session is
989-
// attached by this UI itself, which is not worth a marker.
990-
const marker: u8 = if (!selected and entry.attached) '*' else ' ';
991-
try out.append(alloc, marker);
1010+
if (selected) try out.appendSlice(alloc, style_row_selected);
1011+
1012+
// The leading status column, always exactly one display cell:
1013+
// ● your turn: a bell rang while you were away. Bold blue.
1014+
// • unread output you have not viewed. Dim.
1015+
// * attached by another client.
1016+
// (space) nothing to flag. The selected session is attached by
1017+
// this ui itself, which is not worth a '*'.
1018+
// A bell is an explicit request for you, so it outranks plain
1019+
// unread, which outranks who is holding the session.
1020+
if (entry.bell_idle_ms >= 0) {
1021+
try out.appendSlice(alloc, style_attention);
1022+
try out.appendSlice(alloc, attention_marker);
1023+
try out.appendSlice(alloc, sgr_reset);
1024+
// Restore the row highlight the marker's reset cleared.
1025+
if (selected) try out.appendSlice(alloc, style_row_selected);
1026+
} else if (entry.unread) {
1027+
try out.appendSlice(alloc, style_dim);
1028+
try out.appendSlice(alloc, unread_marker);
1029+
try out.appendSlice(alloc, sgr_reset);
1030+
if (selected) try out.appendSlice(alloc, style_row_selected);
1031+
} else {
1032+
const marker: u8 = if (!selected and entry.attached) '*' else ' ';
1033+
try out.append(alloc, marker);
1034+
}
9921035

9931036
if (width >= 12) {
9941037
// "<m><name...> x ": kill target in the last columns.
@@ -1011,7 +1054,7 @@ pub fn appendSessionTitleRow(
10111054
selected: bool,
10121055
) !void {
10131056
if (width == 0) return;
1014-
if (selected) try out.appendSlice(alloc, style_selected);
1057+
if (selected) try out.appendSlice(alloc, style_row_selected);
10151058
try out.appendSlice(alloc, style_dim);
10161059

10171060
if (entry.title.len > 0 and width > 2) {
@@ -2120,6 +2163,8 @@ const Ui = struct {
21202163
.name = try self.alloc.dupe(u8, name),
21212164
.attached = info.attached,
21222165
.idle_ms = info.idle_ms,
2166+
.unread = info.unread,
2167+
.bell_idle_ms = info.bell_idle_ms,
21232168
.title = try self.alloc.dupe(u8, info.title),
21242169
});
21252170
}
@@ -4193,14 +4238,59 @@ test "sidebar session row is exactly the requested width" {
41934238
try std.testing.expect(std.mem.indexOf(u8, text, "work1234") != null);
41944239
try std.testing.expect(std.mem.endsWith(u8, text, "x "));
41954240

4196-
// Selected rows are wrapped in inverse video; the highlight is
4241+
// Selected rows are given a dark background bar; the highlight is
41974242
// the only selection marker.
41984243
out.clearRetainingCapacity();
41994244
try appendSessionRow(alloc, &out, entry, 24, true);
4200-
try std.testing.expect(std.mem.startsWith(u8, out.items, style_selected));
4245+
try std.testing.expect(std.mem.startsWith(u8, out.items, style_row_selected));
42014246
try std.testing.expect(std.mem.indexOf(u8, out.items, ">") == null);
42024247
}
42034248

4249+
test "sidebar marks your-turn and unread sessions" {
4250+
const alloc = std.testing.allocator;
4251+
var out: std.ArrayList(u8) = .empty;
4252+
defer out.deinit(alloc);
4253+
4254+
var name_buf: [8]u8 = "work1234".*;
4255+
var title_buf: [0]u8 = .{};
4256+
// Attached elsewhere AND a bell rang while away at once, to prove
4257+
// the bell ("your turn") outranks both plain unread and the '*'.
4258+
const entry: Entry = .{
4259+
.name = &name_buf,
4260+
.attached = true,
4261+
.idle_ms = 0,
4262+
.unread = true,
4263+
.bell_idle_ms = 0,
4264+
.title = &title_buf,
4265+
};
4266+
4267+
// The marker is one display cell (the ● glyph), so the row is still
4268+
// exactly 24 columns: 1 marker + 20 name + 3 " x ".
4269+
try appendSessionRow(alloc, &out, entry, 24, false);
4270+
const expected = style_attention ++ attention_marker ++ sgr_reset ++
4271+
"work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset;
4272+
try std.testing.expectEqualStrings(expected, out.items);
4273+
// The bell marker takes priority over the attached-elsewhere '*'.
4274+
try std.testing.expect(std.mem.indexOfScalar(u8, out.items, '*') == null);
4275+
4276+
// Unread with no bell is the dim • marker.
4277+
out.clearRetainingCapacity();
4278+
var unread_only = entry;
4279+
unread_only.bell_idle_ms = -1;
4280+
try appendSessionRow(alloc, &out, unread_only, 24, false);
4281+
const expected_unread = style_dim ++ unread_marker ++ sgr_reset ++
4282+
"work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset;
4283+
try std.testing.expectEqualStrings(expected_unread, out.items);
4284+
4285+
// Selected: the marker's SGR reset must not drop the row highlight,
4286+
// so the background style is re-applied right after the marker.
4287+
out.clearRetainingCapacity();
4288+
try appendSessionRow(alloc, &out, entry, 24, true);
4289+
const expected_sel = style_row_selected ++ style_attention ++ attention_marker ++
4290+
sgr_reset ++ style_row_selected ++ "work1234" ++ (" " ** 12) ++ " x " ++ sgr_reset;
4291+
try std.testing.expectEqualStrings(expected_sel, out.items);
4292+
}
4293+
42044294
test "sidebar title row renders the title dim under the name" {
42054295
const alloc = std.testing.allocator;
42064296
var out: std.ArrayList(u8) = .empty;

0 commit comments

Comments
 (0)