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
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ exactly as a human would see it.

- Sessions that survive disconnects: detach with `C-a d`, reattach with
`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); everything also 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 Expand Up @@ -63,9 +67,11 @@ automatically (pinned in `build.zig.zon`).
boo new # new session running $SHELL, attached
boo new work # named session
boo new work -d -- make # create detached, running a command
boo ui # manage sessions in a full-screen UI
boo ls # list sessions
boo attach work # reattach (steals if attached elsewhere)
boo at w # same: alias + unique-prefix matching
boo rename work api # rename a session
boo kill work # end a session
boo kill --all # end every session
```
Expand All @@ -87,6 +93,11 @@ Bindings follow GNU screen's defaults, including the `C-x` variants
| `C-a l`, `C-a C-l` | redraw |
| `C-a a` | send a literal `C-a` |

`boo ui` adds bindings for switching (`C-a n`/`C-a p`/`C-a C-a`),
creating (`C-a c`), killing (`C-a k`), and renaming (`C-a r`)
sessions; pressing `C-a` alone lists them in the bottom bar. See
`boo help ui`.

## Automation

Everything except `attach` works without a terminal, which makes boo a
Expand Down Expand Up @@ -149,11 +160,12 @@ your terminal <-(raw tty)-> boo client <-(unix socket)-> session daemon
This is a young project, not a drop-in GNU screen replacement:

- One attached client per session (attaching steals); no `-x` sharing.
- One window per session: no splits, tabs, or window juggling. Run one
session per task instead.
- One window per session: no splits or tabs inside a session. Run one
session per task and juggle them with `boo ui`.
- The `C-a` prefix is not yet configurable, and pasted bytes containing
`0x01` are interpreted as the prefix (GNU screen has the same quirk).
- No status line, monitoring, copy mode, or split regions yet.
`0x01` are interpreted as the prefix (GNU screen has the same quirk;
`boo ui` is immune thanks to bracketed paste).
- No status line, monitoring, or copy mode yet.
- Sessions run with `TERM=xterm-256color`.

## License
Expand Down
8 changes: 8 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ pub fn build(b: *std.Build) void {
.link_libc = true,
});
integration_mod.addOptions("build_options", test_opts);
// The tests render captured client output through a terminal
// emulator to assert what a user would actually see.
if (b.lazyDependency("ghostty", .{
.target = target,
.optimize = optimize,
})) |dep| {
integration_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
}

const integration_tests = b.addTest(.{ .root_module = integration_mod });
const run_integration_tests = b.addRunArtifact(integration_tests);
Expand Down
2 changes: 1 addition & 1 deletion build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.{
.name = .boo,
.version = "0.4.0",
.version = "0.5.0",
.fingerprint = 0x8b7acdfd255f0e34,
.minimum_zig_version = "0.15.2",
.dependencies = .{
Expand Down
4 changes: 3 additions & 1 deletion src/client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ pub fn attach(alloc: std.mem.Allocator, socket_path: []const u8) !Outcome {
}
}

fn rawMode(t: *posix.termios) void {
/// Configure a termios for raw byte-at-a-time input. Shared with the
/// boo ui client, which manages its own terminal lifecycle.
pub fn rawMode(t: *posix.termios) void {
t.iflag.IGNBRK = false;
t.iflag.BRKINT = false;
t.iflag.PARMRK = false;
Expand Down
65 changes: 65 additions & 0 deletions src/daemon.zig
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const posix = std.posix;
const protocol = @import("protocol.zig");
const keys = @import("keys.zig");
const altscreen = @import("altscreen.zig");
const paths = @import("paths.zig");
const windowpkg = @import("window.zig");
const Window = windowpkg.Window;
const main = @import("main.zig");
Expand Down Expand Up @@ -58,6 +59,11 @@ pub const Daemon = struct {
conns: std.ArrayList(*Conn) = .empty,
key_parser: keys.Parser = .{},

/// Owned replacements for opts.name and opts.socket_path after a
/// rename; the startup values are borrowed from the caller.
owned_name: ?[]u8 = null,
owned_socket_path: ?[]u8 = null,

rows: u16,
cols: u16,

Expand Down Expand Up @@ -115,6 +121,8 @@ pub const Daemon = struct {
self.conns.deinit(self.alloc);
posix.close(self.opts.listen_fd);
std.fs.cwd().deleteFile(self.opts.socket_path) catch {};
if (self.owned_name) |n| self.alloc.free(n);
if (self.owned_socket_path) |p| self.alloc.free(p);
if (self.sig_read >= 0) posix.close(self.sig_read);
if (sigchld_pipe >= 0) posix.close(sigchld_pipe);
}
Expand Down Expand Up @@ -371,6 +379,12 @@ pub const Daemon = struct {
}
}
conn.send(.ok, out.items);
} else if (std.mem.eql(u8, cmd, "rename")) {
if (argv.len != 2) {
conn.send(.err, "usage: rename <new-name>");
return;
}
self.rename(conn, argv[1]);
} else if (std.mem.eql(u8, cmd, "quit")) {
conn.send(.ok, "");
if (self.win) |w| {
Expand All @@ -383,6 +397,57 @@ pub const Daemon = struct {
}
}

/// Move the session to a new name by renaming the listening
/// socket; established connections survive, and new clients find
/// the session under the new name.
fn rename(self: *Daemon, conn: *Conn, new_name: []const u8) void {
paths.validateName(new_name) catch {
conn.send(.err, "invalid session name");
return;
};
if (std.mem.eql(u8, new_name, self.opts.name)) {
conn.send(.ok, "");
return;
}

const dir = std.fs.path.dirname(self.opts.socket_path) orelse ".";
const new_path = paths.socketPath(self.alloc, dir, new_name) catch {
conn.send(.err, "rename failed");
return;
};
const new_owned_name = self.alloc.dupe(u8, new_name) catch {
self.alloc.free(new_path);
conn.send(.err, "rename failed");
return;
};

// Refuse to clobber another session's socket. Checking first
// is racy, but the window is tiny and losing the race only
// replaces a socket the same way 'kill' would free it.
if (std.fs.cwd().access(new_path, .{})) |_| {
self.alloc.free(new_path);
self.alloc.free(new_owned_name);
conn.send(.err, "a session with that name already exists");
return;
} else |_| {}

std.fs.cwd().rename(self.opts.socket_path, new_path) catch {
self.alloc.free(new_path);
self.alloc.free(new_owned_name);
conn.send(.err, "rename failed");
return;
};

if (self.owned_name) |n| self.alloc.free(n);
if (self.owned_socket_path) |p| self.alloc.free(p);
self.owned_name = new_owned_name;
self.owned_socket_path = new_path;
self.opts.name = new_owned_name;
self.opts.socket_path = new_path;
log.info("renamed to {s}", .{new_name});
conn.send(.ok, "");
}

fn serviceWindow(self: *Daemon, win: *Window, buf: []u8) void {
const n = posix.read(win.pty_fd, buf) catch |err| n: {
// EIO means the slave side is fully closed: window is done.
Expand Down
59 changes: 59 additions & 0 deletions src/help.zig
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@ pub const overview =
\\commands:
\\ new [name] [-d] [-- cmd...] start a session (attach unless -d)
\\ attach, at <name> attach a session (steals politely)
\\ ui manage sessions in a full-screen UI
\\ ls [--json] list sessions
\\ send <name> [flags] type into a session
\\ peek <name> print the session's screen
\\ wait <name> block until output matches or settles
\\ kill <name | --all> end a session, or all of them
\\ rename <name> <new-name> rename a session
\\ version print the version
\\ help [command | topic] this overview, or detailed help
\\
Expand Down Expand Up @@ -106,6 +108,46 @@ pub const commands = [_]Entry{
\\
,
},
.{
.name = "ui",
.body =
\\usage: boo ui
\\
\\Manage sessions in a full-screen interface: a sidebar lists
\\every session (window title underneath) and the focused
\\session runs in a viewport on the right, rendered live from
\\terminal state.
\\
\\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
\\ in the viewport forwarded to the application when it
\\ asked for mouse reporting; otherwise
\\ dragging selects text and copies it on
\\ release (OSC 52)
\\
\\keys (prefix C-a, control variants match GNU screen):
\\ C-a c create a session and focus it
\\ C-a k kill the focused session (asks y/n)
\\ C-a r rename the focused session
\\ C-a n focus the next session
\\ C-a p focus the previous session
\\ C-a C-a focus the previously focused session
\\ C-a d quit the UI (sessions keep running)
\\ C-a l redraw
\\ C-a a send a literal C-a to the application
\\ C-a Esc cancel the armed prefix
\\
\\Pressing C-a alone lists these bindings in the bottom bar.
\\
\\Everything else is typed into the focused session. Unlike a
\\plain attach, pasted text may contain C-a bytes safely
\\(bracketed paste).
\\
,
},
.{
.name = "ls",
.alias = "list",
Expand Down Expand Up @@ -207,6 +249,20 @@ pub const commands = [_]Entry{
\\
,
},
.{
.name = "rename",
.body =
\\usage: boo rename <name> <new-name>
\\
\\Rename a session. The running program is unaffected and an
\\attached client stays attached. The old name accepts a
\\unique prefix, like attach.
\\
\\example:
\\ boo rename work api-server
\\
,
},
.{
.name = "version",
.body =
Expand Down Expand Up @@ -244,6 +300,9 @@ pub const topics = [_]Entry{
\\C-a C-l redraws. Detaching leaves the session running;
\\'boo attach' brings it back.
\\
\\'boo ui' adds bindings for managing sessions; see
\\'boo help ui'.
\\
,
},
.{
Expand Down
55 changes: 52 additions & 3 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ const daemonpkg = @import("daemon.zig");
const help = @import("help.zig");
const paths = @import("paths.zig");
const protocol = @import("protocol.zig");
const ui = @import("ui.zig");

pub const version = "0.4.0";
pub const version = "0.5.0";

/// Exit codes, documented in `boo help`.
const exit_runtime: u8 = 1;
Expand Down Expand Up @@ -64,11 +65,13 @@ pub fn main() !void {

if (eql(cmd, "new")) return cmdNew(alloc, rest);
if (eql(cmd, "attach") or eql(cmd, "at")) return cmdAttach(alloc, rest);
if (eql(cmd, "ui")) return cmdUi(alloc, rest);
if (eql(cmd, "ls") or eql(cmd, "list")) return cmdLs(alloc, rest);
if (eql(cmd, "send")) return cmdSend(alloc, rest);
if (eql(cmd, "peek")) return cmdPeek(alloc, rest);
if (eql(cmd, "wait")) return cmdWait(alloc, rest);
if (eql(cmd, "kill")) return cmdKill(alloc, rest);
if (eql(cmd, "rename")) return cmdRename(alloc, rest);
if (eql(cmd, "version") or eql(cmd, "-V") or eql(cmd, "--version")) return cmdVersion(alloc);
if (eql(cmd, "help") or eql(cmd, "-h") or eql(cmd, "--help")) return cmdHelp(alloc, rest);
fail(exit_usage, "unknown command '{s}' (run 'boo help')", .{cmd});
Expand Down Expand Up @@ -146,7 +149,7 @@ fn resolveSession(
fail(exit_no_session, "no session matching '{s}' (run 'boo ls')", .{want});
}

const SessionInfo = struct {
pub const SessionInfo = struct {
/// Full info payload:
/// name \t Attached|Detached \t idle_ms \t out_idle_ms \t title.
text: []u8,
Expand All @@ -159,7 +162,7 @@ const SessionInfo = struct {
};

/// Query a session daemon, deleting the socket when the daemon is gone.
fn sessionInfo(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) !?SessionInfo {
pub fn sessionInfo(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) !?SessionInfo {
const sock = try paths.socketPath(alloc, dir, name);
defer alloc.free(sock);
const result = client.control(alloc, sock, &.{"info"}) catch {
Expand Down Expand Up @@ -329,6 +332,21 @@ fn attachLoop(alloc: std.mem.Allocator, dir: []const u8, name: []const u8) !void
}
}

fn cmdUi(alloc: std.mem.Allocator, args: []const [:0]const u8) !void {
for (args) |arg| {
if (isHelpFlag(arg)) return printHelpPage("ui");
usageFail("ui", "unexpected argument '{s}'", .{arg});
}

const dir = try paths.socketDir(alloc);
defer alloc.free(dir);
ui.run(alloc, dir) catch |err| switch (err) {
error.NotATty => fail(exit_runtime, "ui requires a terminal", .{}),
else => return err,
};
std.debug.print("[boo ui closed]\n", .{});
}

fn cmdLs(alloc: std.mem.Allocator, args: []const [:0]const u8) !void {
var json = false;
for (args) |arg| {
Expand Down Expand Up @@ -733,6 +751,36 @@ fn cmdKill(alloc: std.mem.Allocator, args: []const [:0]const u8) !void {
if (!result.ok) fail(exit_runtime, "{s}", .{result.text});
}

fn cmdRename(alloc: std.mem.Allocator, args: []const [:0]const u8) !void {
var old_arg: ?[]const u8 = null;
var new_arg: ?[]const u8 = null;
for (args) |arg| {
if (isHelpFlag(arg)) return printHelpPage("rename");
if (arg.len > 0 and arg[0] == '-') {
usageFail("rename", "unknown flag '{s}'", .{arg});
} else if (old_arg == null) {
old_arg = arg;
} else if (new_arg == null) {
new_arg = arg;
} else {
usageFail("rename", "unexpected argument '{s}'", .{arg});
}
}
const want = old_arg orelse usageFail("rename", "a session name is required", .{});
const new_name = new_arg orelse usageFail("rename", "a new session name is required", .{});
paths.validateName(new_name) catch
usageFail("rename", "invalid session name '{s}'", .{new_name});

const dir = try paths.socketDir(alloc);
defer alloc.free(dir);
const name = try resolveSession(alloc, dir, want);
defer alloc.free(name);

const result = try mustControl(alloc, dir, name, &.{ "rename", new_name });
defer alloc.free(result.text);
if (!result.ok) fail(exit_runtime, "{s}", .{result.text});
}

fn cmdVersion(alloc: std.mem.Allocator) !void {
try stdoutPrint(alloc, "boo {s}\n", .{version});
}
Expand Down Expand Up @@ -957,4 +1005,5 @@ test {
_ = @import("daemon.zig");
_ = @import("client.zig");
_ = @import("help.zig");
_ = @import("ui.zig");
}
Loading
Loading