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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion src/daemon.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
};

Expand All @@ -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);
Expand All @@ -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 {
Expand Down
15 changes: 13 additions & 2 deletions src/help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
\\
,
},
Expand Down Expand Up @@ -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"}
\\
Expand Down
35 changes: 32 additions & 3 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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,
};
}
Expand Down Expand Up @@ -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);
Expand Down
114 changes: 102 additions & 12 deletions src/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
// "<m><name...> x ": kill target in the last columns.
Expand All @@ -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) {
Expand Down Expand Up @@ -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),
});
}
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading