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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ exactly as a human would see it.
`boo attach`.
- A full-screen session manager: `boo ui` lists sessions in a sidebar
with their titles, and renders the focused one next to it. Click to
switch, create, kill, or rename sessions; drag to select and copy
text (OSC 52); scroll the wheel to page through a session's history;
everything also works from the keyboard.
switch or kill sessions; drag to select and copy text (OSC 52);
scroll the wheel to page through a session's history; create,
rename, and everything else works from the keyboard.
- One command per session, named after your current directory by
default. Sessions are cheap; run one per task.
- Faithful redraws from libghostty terminal state, including SGR styles,
Expand Down
1 change: 0 additions & 1 deletion src/help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,6 @@ pub const commands = [_]Entry{
\\mouse:
\\ click a session focus it (steals politely, like attach)
\\ click its 'x' kill it (asks for confirmation)
\\ click + new session start a session running $SHELL
\\ scroll the sidebar scroll the session list
\\ wheel in viewport scroll the session's history; wheel
\\ back down or press esc to return to
Expand Down
53 changes: 14 additions & 39 deletions src/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,6 @@ pub const Layout = struct {
/// Each session occupies two sidebar rows: name and title.
pub const entry_rows: u16 = 2;

/// Sidebar rows above the session list: the new-session button
/// and a separating blank row.
pub const list_top: u16 = 2;

pub fn init(rows: u16, cols: u16) Layout {
// Narrow terminals get a proportionally smaller sidebar; the
// viewport keeps at least a sliver so the focused session
Expand All @@ -92,11 +88,10 @@ pub const Layout = struct {
return self.sidebar_w + 1;
}

/// Sidebar rows available for session entries between the
/// new-session button (plus its gap row) and the keybind hint
/// on the bottom row.
/// Sidebar rows available for session entries: everything above
/// the keybind hint on the bottom row.
pub fn listRows(self: Layout) u16 {
return self.rows -| (list_top + 1);
return self.rows -| 1;
}

/// Whole session entries that fit in the list area.
Expand All @@ -108,7 +103,6 @@ pub const Layout = struct {
/// Display row within the visible session list (entry_rows
/// rows per session; scroll applied by the caller).
session: struct { row: u16, kill: bool },
new_button,
viewport: struct { x: u16, y: u16 },
none,
};
Expand All @@ -122,10 +116,8 @@ pub const Layout = struct {
}
if (x >= self.sidebar_w) return .none; // separator column
if (y == self.rows -| 1) return .none; // keybind hint row
if (y == 0) return .new_button;
if (y < list_top) return .none; // gap under the button
return .{ .session = .{
.row = y - list_top,
.row = y,
.kill = self.sidebar_w >= 12 and x == self.sidebar_w - 2,
} };
}
Expand Down Expand Up @@ -1249,10 +1241,6 @@ const Ui = struct {
}
self.focusIndex(idx);
},
.new_button => {
if (m.release or m.isMotion()) return;
self.createSession();
},
else => {},
}
}
Expand Down Expand Up @@ -2377,24 +2365,11 @@ const Ui = struct {
try out.appendSlice(alloc, sgr_reset);
return;
}
if (y == 0) {
try out.appendSlice(alloc, style_dim);
try appendClipped(alloc, out, " + new session", w);
try out.appendSlice(alloc, sgr_reset);
return;
}
if (y < Layout.list_top) {
// Blank gap between the button and the session list.
try appendClipped(alloc, out, "", w);
return;
}

const row = y - Layout.list_top;
const idx = self.scroll + row / Layout.entry_rows;
const idx = self.scroll + y / Layout.entry_rows;
if (idx < self.sessions.items.len) {
const entry = self.sessions.items[idx];
const selected = self.selected != null and self.selected.? == idx;
if (row % Layout.entry_rows == 0) {
if (y % Layout.entry_rows == 0) {
try appendSessionRow(alloc, out, entry, w, selected);
} else {
try appendSessionTitleRow(alloc, out, entry, w, selected);
Expand Down Expand Up @@ -2885,13 +2860,13 @@ test "layout: geometry and hit testing" {
try std.testing.expectEqual(@as(u16, 75), l.viewportCols());
try std.testing.expectEqual(@as(u16, 25), l.viewportX());
try std.testing.expectEqual(@as(u16, 24), l.viewportRows());
try std.testing.expectEqual(@as(usize, 10), l.visibleEntries());
try std.testing.expectEqual(@as(usize, 11), l.visibleEntries());

// The new-session button is the top row and a blank gap sits
// under it. The bottom sidebar row holds the keybind hint, and
// the viewport extends through the last row.
try std.testing.expectEqual(Layout.Hit.new_button, l.hit(3, 0));
try std.testing.expectEqual(Layout.Hit.none, l.hit(3, 1));
// The session list starts on the top row. The bottom sidebar row
// holds the keybind hint, and the viewport extends through the
// last row.
try std.testing.expectEqual(@as(u16, 0), l.hit(3, 0).session.row);
try std.testing.expectEqual(@as(u16, 1), l.hit(3, 1).session.row);
try std.testing.expectEqual(Layout.Hit.none, l.hit(3, 23));
const bottom = l.hit(80, 23);
try std.testing.expectEqual(@as(u16, 55), bottom.viewport.x);
Expand All @@ -2901,10 +2876,10 @@ test "layout: geometry and hit testing" {

// Sessions take two display rows: name, then title.
const s = l.hit(3, 5);
try std.testing.expectEqual(@as(u16, 3), s.session.row);
try std.testing.expectEqual(@as(u16, 5), s.session.row);
try std.testing.expect(!s.session.kill);
const k = l.hit(22, 4);
try std.testing.expectEqual(@as(u16, 2), k.session.row);
try std.testing.expectEqual(@as(u16, 4), k.session.row);
try std.testing.expect(k.session.kill);

const v = l.hit(30, 7);
Expand Down
38 changes: 16 additions & 22 deletions test/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,6 @@ test "ui: sidebar lists sessions and the focused session renders in the viewport
defer ui.deinit();
try ui.waitFor("aa");
try ui.waitFor("bb");
try ui.waitFor("+ new session");
try ui.waitFor("BB-VIEW-MARK");

// The UI renders on the alternate screen, like attach.
Expand All @@ -1174,7 +1173,7 @@ test "ui: dragging in the viewport selects text and copies it via osc 52" {

var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100);
defer ui.deinit();
try ui.waitFor("+ new session");
try ui.waitFor("cp");

// The echoed line lands on the session's first row, rendered at
// screen row 1 starting at column 26 (24-column sidebar plus the
Expand Down Expand Up @@ -1229,7 +1228,7 @@ test "ui: a row touching the viewport's right edge keeps its last cell" {

var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100);
defer ui.deinit();
try ui.waitFor("+ new session");
try ui.waitFor("edge");

// Paint a marker whose final cell sits in the session's last
// column (75 wide inside a 100-column UI), which lands in the
Expand All @@ -1252,12 +1251,8 @@ test "ui: a row touching the viewport's right edge keeps its last cell" {
defer alloc.free(screen);
try std.testing.expect(std.mem.indexOf(u8, screen, "EDGEZ") != null);

// The sidebar separates the new-session button from the first
// session row with a blank gap row.
// The session list starts on the first sidebar row.
var lines = std.mem.splitScalar(u8, screen, '\n');
_ = lines.next(); // button row
const gap = lines.next().?;
try std.testing.expect(std.mem.startsWith(u8, gap, " " ** 24 ++ "\u{2502}"));
const first = lines.next().?;
try std.testing.expect(std.mem.indexOf(u8, first, "edge") != null);
}
Expand Down Expand Up @@ -1301,11 +1296,11 @@ test "ui: clicking a session in the sidebar focuses it" {
defer ui.deinit();
try ui.waitFor("TWO-MARK"); // most recent session focused

// Sessions are sorted by name: "one" on sidebar row 3 (1-based,
// under the button and its gap row). An SGR press + release on
// that row switches the viewport.
// Sessions are sorted by name: "one" on sidebar row 1 (1-based,
// top of the list). An SGR press + release on that row switches
// the viewport.
ui.clearOutput();
try ui.send("\x1b[<0;5;3M\x1b[<0;5;3m");
try ui.send("\x1b[<0;5;1M\x1b[<0;5;1m");
try ui.waitFor("ONE-MARK");
}

Expand All @@ -1321,10 +1316,9 @@ test "ui: create and kill sessions from the ui" {
defer ui.deinit();
try ui.waitFor("keep2");

// Clicking '+ new session' (the top sidebar row) creates a
// session (named after the cwd or the creating pid) and focuses
// it.
try ui.send("\x1b[<0;5;1M\x1b[<0;5;1m");
// C-a c creates a session (named after the cwd or the creating
// pid) and focuses it.
try ui.send("\x01c");
try waitUiSessionCount(&h, 3);

// C-a k asks for confirmation, then kills the focused (new)
Expand Down Expand Up @@ -1354,8 +1348,8 @@ test "ui: clicking the kill target asks for confirmation" {
try ui.waitFor("victim");

// The kill target is the 'x' in the second-to-last sidebar
// column (sidebar width 24 -> 1-based column 23), row 3.
try ui.send("\x1b[<0;23;3M\x1b[<0;23;3m");
// column (sidebar width 24 -> 1-based column 23), row 1.
try ui.send("\x1b[<0;23;1M\x1b[<0;23;1m");
try ui.waitFor("kill victim? y/n");
try ui.send("y");
try waitUiSessionCount(&h, 0);
Expand All @@ -1374,9 +1368,9 @@ test "ui: killing the focused session moves focus to the next one" {
defer ui.deinit();
try ui.waitFor("stay");

// Focus "doomed" (first alphabetically, sidebar row 3) so its
// Focus "doomed" (first alphabetically, sidebar row 1) so its
// death has somewhere to fall back to.
try ui.send("\x1b[<0;5;3M\x1b[<0;5;3m");
try ui.send("\x1b[<0;5;1M\x1b[<0;5;1m");
try h.sendLine("doomed", "DOOM-MARK");
try ui.waitFor("DOOM-MARK");

Expand Down Expand Up @@ -1517,7 +1511,7 @@ test "ui: a plain attach steals the focused session" {
try ui.waitFor("attached elsewhere");

// Clicking the session in the sidebar steals it back.
try ui.send("\x1b[<0;5;3M\x1b[<0;5;3m");
try ui.send("\x1b[<0;5;1M\x1b[<0;5;1m");
try thief.waitFor("attached elsewhere");
_ = try thief.waitExit();
}
Expand Down Expand Up @@ -1931,7 +1925,7 @@ test "ui: wheel scrolls primary-screen scrollback" {

var ui = try PtyClient.spawn(&h, &.{"ui"}, 24, 100);
defer ui.deinit();
try ui.waitFor("+ new session");
try ui.waitFor("scrolly");

// Stream enough lines through cat that the earliest ones scroll
// off the 24-row screen into the view's scrollback.
Expand Down
Loading