@@ -32,8 +32,12 @@ const windowpkg = @import("window.zig");
3232
3333const 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.
3842const 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
859870const sgr_reset = "\x1b [0m" ;
871+ /// Reverse video, used to highlight an in-progress mouse text selection
872+ /// over viewport content.
860873const 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" ;
861878const 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.
9781002pub 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+
42044294test "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