src/tui/wallview.zig
Ref: Size: 129.9 KiB History
//! The CLI wall: N sessions in one terminal. It lists daemons (hosts.zig)
//! and a host's live sessions are its tiles, polled. Every tile claims its
//! rectangle and focus is client-local. One thread per tile owns that tile's
//! transport both ways, so the keyboard hands it bytes over a mailbox.
const std = @import("std");
const proto = @import("term").protocol;
const client = @import("client");
const hosts = @import("client").hosts;
const handoff = @import("client").handoff;
const askpass = @import("client").askpass;
const spawn = @import("spawn");
const proxy = @import("proxy");
const grid_mod = @import("term").grid;
const paint = @import("paint.zig");
// Counters ride out through `Shared` because a detached pump never reaches
// a `Core.deinit`.
// The chord table and the prediction hooks a focused tile shares with the
// client: one interaction core, not a second copy (interact.zig).
const interact = @import("interact.zig");
const layout = @import("client").layout;
// Where a per-wall socket lives, asked of the row that owns the answer for
// the daemon socket too: one spelling of the runtime directory, not a
// second getenv beside it.
const sockpath = @import("sockpath");
const wall_host = @import("wall_host.zig");
const wall_layout = @import("wall_layout.zig");
const wall_picker = @import("wall_picker.zig");
const wall_pump = @import("wall_pump.zig");
const Host = wall_host.Host;
const PickerAuto = wall_picker.PickerAuto;
const Resolved = wall_host.Resolved;
// `wallview` is this module's face: mux_main reaches these through
// the root, wherever inside the module they now live.
pub const HostSpec = client.HostSpec;
pub const resolveHost = client.resolveHost;
/// Whether this process has a screen to cut stripes on.
fn headless(out_fd: std.posix.fd_t) bool {
// A `mux` with no measurable terminal is a wall of ONE, writing exactly
// what the plain client wrote: everything else a wall does is stripes.
return interact.ttySize(out_fd) == null;
}
pub const State = enum {
/// A pending pane: nothing has dialed and nothing will until the
/// host's own list names its session. `.connecting` would be a lie.
waiting,
/// A pending pane whose host's poll failed: the picker row's word,
/// worn on the pane, so the wall itself says which setup is dark.
@"unreachable",
/// A pending pane whose REACHABLE host answered without its session:
/// the host said no, not nothing. The pane stands — its rect is the
/// user's saved layout, not the daemon's state — until Enter re-creates
/// the session in place or `x` dismisses it. Reversible like
/// `unreachable`: the tile stays pending, so a list that names the
/// session later binds it.
gone,
connecting,
up,
reconnecting,
exited,
refused,
/// The user pressed Esc at ssh's prompt. A state and not a notice: the
/// tile stays on the wall wearing the reason, the way a lost one does,
/// and the wall it is on is still there to birth from again.
declined,
pub fn word(s: State) []const u8 {
return switch (s) {
.waiting => "waiting",
.@"unreachable" => "unreachable",
.gone => "session ended",
.connecting => "connecting",
.up => "up",
.reconnecting => "reconnecting",
.exited => "exited",
.declined => "prompt declined",
.refused => "refused",
};
}
};
/// The mailbox: how many typed bytes can wait for a pump to put them on
/// the wire. One stdin read's worth, which is all a keyboard can produce
/// between two turns of a pump's poll loop.
pub const mailbox_max = 4096;
/// The most tiles one wall can hold. A hard bound, not a growable array:
/// pump threads hold `*Tile` pointers, so growing would move it under them.
/// DEFINED FROM the layout's bound rather than beside it: the file is the
/// wall, both fronts read it, and two spellings of 32 agreeing by comment
/// is a drift waiting for whoever raises one of them.
pub const max_tiles: usize = client.layout.max_leaves;
/// Why a tile's pump stopped, for the keyboard that has to decide what that
/// means for the WALL. `State` narrates the same events on a label bar;
/// this is the machine-readable half, and they are separate because a cold
/// loss has no label word of its own and an exit carries a code.
pub const EndReason = enum(u8) { none, exited, taken, refused, lost, no_thread, declined };
pub const Shared = struct {
running: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
/// Serializes every write to the terminal. Held (and never released)
/// at teardown, so no stripe paints across the restore, and held
/// across a focus move, so no stripe paints into the gap between the
/// screen being cleared and the terminal changing hands.
paint_mu: std.Thread.Mutex = .{},
out_fd: std.posix.fd_t,
/// Whether there is a terminal here at all. Every WALL-level write is
/// gated on it: on a pipe mux writes the grid and no terminal state.
is_tty: bool,
/// Where this wall is written, or null for a wall that persists nothing
/// (a piped `mux`, a test). Set once by `run` on a terminal. The ONE
/// gate every save reads, so a test points it at a file and proves what
/// an operation wrote rather than that a save was called.
layout_path: ?[]const u8 = null,
/// This client's own version, what a host's stated version is judged
/// against for the bar's drift word. Empty means unknown, which judges
/// nobody — see `driftWord`. Set once before any pump exists, read-only
/// after, so it rides `Shared` without a lock.
own_version: []const u8 = "",
/// The pumps' doorbell to the KEYBOARD — the mirror of `Tile.wake_w`.
/// Without it a shell that exits goes unnoticed until the next keystroke.
kb_r: std.posix.fd_t = -1,
kb_w: std.posix.fd_t = -1,
/// The focused tile's prediction counters. Published OUT to the driver
/// that owns the exit, because a pump is a detached thread that never
/// reaches a `deinit` to print `MUX_PREDICT_STATS` itself.
stats: interact.PredictCounters = .{},
/// One sentence for the next focused tile to put in its corner. The
/// keyboard has no painter for a LIVE tile — `Core.banner` belongs to a
/// Core — so it leaves the message where that pump will find it.
notice: [96]u8 = undefined,
notice_len: usize = 0,
/// The terminal's shape: measured at startup, and re-measured by the
/// KEYBOARD thread on a SIGWINCH (the flag is process-wide, and only
/// the thread that lays the wall out may act on it). Stripes are cut
/// from it and a one-tile wall claims exactly it.
size: proto.Size,
/// The FOCUSED tile: where input goes, and whose pump holds the terminal.
/// Under `paint_mu` so a repaint draws the focus the keyboard has now.
sel: usize = 0,
/// Where the focused tile's last paint left the cursor. The cursor
/// belongs to the focus: an unfocused paint's last act puts it back here.
cursor: grid_mod.CursorPos = .{ .x = 0, .y = 0 },
/// The container tree that owns every tile's rect. The keyboard thread
/// mutates it under `paint_mu` — the same single-writer rule as `sel`
/// — and relayout flattens it to read rects.
tree: layout.Tree = layout.Tree.init(std.heap.page_allocator),
/// The last flatten result, kept so `focus_dir` can read adjacency
/// without re-flattening under the keyboard's `paint_mu` hold.
/// Relayout replaces it (freeing the old one); the keyboard thread
/// is the sole reader, under the same ownership as `tree`.
last_flat: ?layout.Flat = null,
/// The non-fullscreen flatten, kept so `focus_dir` can answer from
/// the base layout while fullscreened — the fullscreen flat gives
/// every non-focused tile a 0×0 rect, so adjacency on it is null.
base_flat: ?layout.Flat = null,
flat_alloc: std.mem.Allocator = std.heap.page_allocator,
/// `f` toggles it; relayout passes `sel` to flatten when set, so the
/// focused tile takes the whole terminal and every other gets 0×0.
fullscreen: bool = false,
/// Bumped when the terminal's contents are no longer anybody's paint.
/// The only thing that repaints a tile whose session has gone quiet.
repaint_gen: std.atomic.Value(u64) = std.atomic.Value(u64).init(0),
/// True while the host picker's popup is on the terminal. Every paint
/// asks: a tile redrawing its rect under the box would erase the rows
/// the user is choosing from. Their replicas stay hot the whole time,
/// and the close bumps `repaint_gen` so every rect comes back.
picker_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// The popup's last frame: its hash, so an unchanged one is not
/// rewritten, and the box it wrote, so a SHORTER one clears the rows it
/// no longer covers. Keyboard-thread only, like the picker itself.
picker_frame: PickerFrame = .{},
/// The picker's end two-step, per host and name: a second `x` on the SAME
/// row inside the window forces, any other row is a first press.
/// Keyboard-thread only, like the picker itself.
pick_end: PickEnd = .{},
/// True while ssh's prompt box is on the terminal. `picker_open`'s
/// twin, for its reason and at the same gates — and a second flag
/// rather than a mode of the first, because a prompt ARRIVES and can
/// arrive with the picker already up.
ask_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// Where this wall answers ssh's prompts, and the binary ssh execs to
/// ask. Null is "ssh keeps its own prompts": without the runtime
/// directory `sockpath.runtimeDir` names, the socket would land on a
/// shared /tmp, answerable by any local user.
prompts: ?*askpass.Listener = null,
prompt_exe: []const u8 = "",
/// One label bar per tile on a tty, however many tiles there are: the
/// bar is what names a session on screen, and hiding it when one tile
/// was visible left a solo or fullscreened session anonymous. A piped
/// wall owns every row and draws none — its byte stream is a script's
/// input, and a bar in it would be bytes the session never wrote.
/// Derived, never assigned: `viewRows`, `core.row_off` and the layout
/// floors (`wall_layout.floorsOf`) all read this one function, so no
/// site can drift from the rule the way three assignment copies could.
pub fn labelRows(s: *const Shared) u16 {
return @intFromBool(s.is_tty);
}
};
/// What the popup left on the screen, as the next paint needs it: the hash
/// of the bytes, and the box those bytes covered. ONE value rather than two
/// fields, because the two facts are invalidated together — every place that
/// says the frame is no longer on the screen (a close, a relayout under it,
/// the ssh prompt box painting over it) clears this whole struct, and a site
/// that zeroed only the hash would leave the next paint erasing a rect that
/// now belongs to a tile. `stamp` 0 is "nothing of the box is on the
/// screen", and the rect means nothing then.
pub const PickerFrame = struct {
stamp: u64 = 0,
top: u16 = 0,
left: u16 = 0,
rows: u16 = 0,
cols: u16 = 0,
};
/// The picker's `x`, which is a two-step the client only REMEMBERS: the
/// daemon refuses the first press with a count, and the second inside the
/// window says the user meant it. Keyed by host AND name because the same
/// session name lives on two daemons, and because a press that moved to
/// another row is a question about another session — it must be a first
/// press, not a force inherited from the row above.
pub const PickEnd = struct {
host: usize = 0,
name: client.SessionName = .{},
until: i64 = 0,
pub fn armedFor(self: *const PickEnd, host: usize, name: []const u8, now: i64) bool {
return now < self.until and self.host == host and std.mem.eql(u8, self.name.slice(), name);
}
pub fn arm(self: *PickEnd, host: usize, name: []const u8, until: i64) void {
self.host = host;
self.name = client.SessionName.of(name);
self.until = until;
}
pub fn clear(self: *PickEnd) void {
self.until = 0;
}
};
/// Opens the prompt box on whatever ssh is asking, if anything is. False
/// when there is nothing to show or a box is already up: `take` is the
/// transition, so a doorbell rung twice opens one popup.
fn takeAsk(shared: *Shared, prefix: *interact.PrefixFilter, out: *askpass.Prompt) bool {
if (prefix.asking) return false;
const l = shared.prompts orelse return false;
if (!l.take(out)) return false;
prefix.askOpen(out.kind);
return true;
}
/// The prompt box gives the terminal back — whichever end ended it.
pub fn closeAsk(
w: Wall,
shared: *Shared,
prefix: *interact.PrefixFilter,
picker_sel: usize,
picker_row: usize,
) void {
// ONE function: `relayout` CLEARS the screen, so a picker still open
// under the box owes a repaint. The answer buffer is zeroed here and
// nowhere else — `feed` cannot, since the action it returned borrows it.
prefix.askClose();
shared.ask_open.store(false, .release);
// `relayout` bumps `repaint_gen`, which is the only thing that redraws
// a tile whose session said nothing while the box was up.
wall_layout.relayout(w, shared.sel);
if (!prefix.picking) return;
var foot: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
const back = wall_picker.pickerRepaint(&foot, prefix, true);
wall_picker.paintPicker(w, picker_sel, .{ .level = prefix.pick_level, .row = picker_row }, back.line);
}
/// The host picker gives the terminal back — the key that closed it, or the
/// auto-open deciding the wall no longer needs it. `shown` is the keyboard
/// loop's own mirror of `prefix.picking`, cleared here so the two cannot
/// disagree about whose screen it is. `birth_at` is the tile a picker Enter
/// just made: the focus goes there, re-cutting on the way.
fn closePicker(
w: Wall,
prefix: *interact.PrefixFilter,
shown: *bool,
birth_at: ?usize,
) void {
// ONE function: the close owes a `relayout`, which CLEARS the screen the
// popup was painted over and bumps `repaint_gen` — the only thing that
// redraws a tile whose session said nothing while the box was up.
prefix.picking = false;
// The level goes home with the box, whichever end closed it: a popup
// reopened after an auto-close must be a HOST list, and only the keys
// that close it themselves put the level back.
prefix.pick_level = .hosts;
shown.* = false;
w.shared.picker_open.store(false, .release);
// The screen under the box is about to be redrawn, so the next open owes
// a paint however identical its rows are, and owes no clear for a box
// the redraw has already taken off the screen.
w.shared.picker_frame = .{};
if (birth_at) |at|
focusAnswer(w, true, at)
else
wall_layout.relayout(w, w.shared.sel);
}
/// Whether a popup owns the terminal.
pub fn popupOpen(shared: *const Shared) bool {
// Every tile paint asks: a rect redrawn under either box would erase
// what the user is reading, and the close bumps `repaint_gen` to bring
// the rects back.
return shared.picker_open.load(.acquire) or shared.ask_open.load(.acquire);
}
/// The rect a paint used, in the only terms a click can be resolved in.
/// Absolute rows count from the oldest row the DAEMON retains, so a grid row
/// alone names nothing without the history count its frame carried.
pub const Tile = struct {
r: Resolved,
rect: layout.Rect,
shared: *Shared,
/// This tile's place in the wall: what the focus and the `1`-`9`
/// jump are indices into.
idx: usize,
/// Which host on the wall this tile belongs to, as an index into the
/// run's host table. Null for a tile no host poll owns, which the poll
/// therefore never vanishes.
host: ?usize = null,
/// Whether this tile has ever had state from its session — the pump's
/// `.up`. A CREATING tile that has not is a session the daemon does not
/// have YET, not one it has dropped: the poll that races a chord-born
/// tile's attach must not vanish the tile it is racing.
ever_up: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// One list already failed to name this tile's session while its pump
/// was still alive. Keyboard-thread only, like the diff that sets it.
missed_once: bool = false,
/// A pane seeded from the layout sidecar: a rect and a name, no pump.
/// Keyboard-thread only; `bindTile` is the one clear.
pending: bool = false,
/// This tile's interaction core, set by its OWN pump thread once the
/// core exists and dereferenced only inside that pump's paint-end hook.
/// No lifetime beyond the pump's scope: the core is freed before the
/// pump returns, and no other thread reads this.
core: ?*interact.Core = null,
/// The state its label last narrated. Owned by the pump but stored
/// here (under `paint_mu`) because the KEYBOARD repaints this bar too,
/// when the focus moves, and it has no other way to know it.
state: State = .connecting,
/// Whether this tile's pump thread is still running. Only pumps answer
/// `repaint_gen`, so a DEAD pump's bar is the one paint the keyboard
/// makes for a tile — drawing over a live one would stale it.
alive: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
/// Whether this slot's pump thread has RETURNED — the half of "free"
/// that `present` cannot say. Reusing the slot without it overwrites a
/// `*Tile` the returning thread is still reading.
pump_done: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// Forgotten by `Ctrl-\ x`: off the wall, and off the wire as soon as the
/// pump notices. "Remove is detach" — the session is untouched. Distinct
/// from `alive`: a tile the user removed, a dead one still narrates.
removed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// The doorbell. The keyboard writes one byte here to wake this tile's
/// pump; the bytes themselves carry nothing, the mailbox does. Both
/// ends are non-blocking, which is what makes a bell that is already
/// ringing free to ring again.
wake_r: std.posix.fd_t = -1,
wake_w: std.posix.fd_t = -1,
/// Bytes typed at this tile while it was focused. The keyboard writing
/// ONLY to the focused tile is the whole enforcement of "input goes to
/// the focus", so the pump sends what it finds without re-checking.
in_mu: std.Thread.Mutex = .{},
in: [mailbox_max]u8 = undefined,
in_len: usize = 0,
/// The ENTRY tile's link, adopted instead of dialled: `mux HOST` dials on
/// the main thread while it still has the tty, so ssh can prompt.
pre: ?client.Transport = null,
/// Whether this tile's attach may CREATE the session it names. The
/// daemon reads create-vs-join off the attach's size claim, so a tile
/// that only joins what a host's list already has must attach at 0x0;
/// the entry tile and a chord-born tile carry the rect.
creates: bool = false,
/// How a chord-born tile enters the tree when its session name arrives.
/// Set by the keyboard with `.ask`, read by the answer handler.
pending_place: Place = .beside_focus,
/// Where a chord-created tile came from, so a REFUSED one has somewhere
/// to put the focus back — the plain client's `.refused` recovery, which
/// was the one attach failure it did not exit on. Null for tiles that
/// no tile created.
born_from: ?usize = null,
/// Whether a wall outlives this tile's ending. Read in exactly one
/// case, the tail of `endAction`: the LAST pane of a wall on a terminal
/// ending for something other than a clean exit — a refused, lost or
/// thread-less dial. Two panes reach that line for opposite reasons and
/// `born_from` is null for both. The entry tile is also the one tile
/// whose `retry_cold` is false, so that field COULD tell the two apart
/// today — but it answers a different question (is a link that died
/// before any state arrived worth redialling), and hanging the wall's
/// survival off it would mean one edit to the retry rule silently
/// changes what a refused `mux TARGET` does. This flag says the one
/// thing it is named for and nothing else reads it.
/// A picker birth onto an empty wall is one session saying no, and
/// the wall the user still has hosts on stands. The ENTRY tile is this
/// `mux`: its refusal is the program's, message and exit code and all,
/// which is what `mux TARGET` on a full daemon has to say. Deleting the
/// flag was tried and cannot work — with it gone both cases take
/// whichever of the two answers is written, and one of them is wrong.
keeps_wall: bool = false,
/// Whether a link that died before this tile saw state is worth retrying.
/// False for the ENTRY tile — a bad host or a typo'd command helps nobody
/// to retry. True for wall tiles: a wall is a thing you leave up.
retry_cold: bool = true,
/// The host's version-drift word as the keyboard last applied it, empty
/// when there is none to say. A `paint_mu` field like `state`: written
/// by the keyboard in `wall_host.applyHostList`, read wherever the bar
/// is painted — the pump paints its own bar, and a pending pane's is
/// painted by the keyboard, so the word has to live where both look.
drift: [drift_max]u8 = undefined,
drift_len: usize = 0,
/// Why this tile's pump stopped, and with what code. Written BEFORE
/// `alive` clears, so a keyboard that sees a dead tile can always read
/// a reason for it.
end: std.atomic.Value(u8) = std.atomic.Value(u8).init(@intFromEnum(EndReason.none)),
code: std.atomic.Value(u8) = std.atomic.Value(u8).init(0),
/// Whether the keyboard has already acted on this tile's end. Keyboard
/// state, so no lock: nothing else reads or writes it.
end_seen: bool = false,
/// A focus chord's question, posted by the keyboard for this tile's pump
/// to put on the wire — `client.SwitchIntent` as a u8, taken with a swap
/// so one keystroke asks one question. The PUMP asks: it owns the link.
ask: std.atomic.Value(u8) = std.atomic.Value(u8).init(0),
/// Until when a second `Ctrl-\ X` on this tile FORCES the end. Written
/// by the pump from the daemon's refusal, read by the keyboard on the
/// next press — atomic because those are two threads, and a torn i64 is
/// a force nobody asked for.
end_armed_until: std.atomic.Value(i64) = std.atomic.Value(i64).init(0),
/// The pump's answer: the name the ring (or `nextFreeName`) landed on.
/// An EMPTY name is the honest "nowhere to go" — a one-session daemon,
/// or a reply that does not name the session we are standing in.
ans_mu: std.Thread.Mutex = .{},
ans: client.SessionName = .{},
ans_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// `Ctrl-\ d`: the keyboard wants this tile's slot handed back to the
/// daemon before the process dies, rather than left for the socket's
/// death to be noticed. Only the pump may write the frame, so the
/// keyboard asks and waits briefly for the ack.
detach_req: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
detach_ack: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// A chunk this tile's mailbox had no room for, and so never sent.
/// Sticky until the pump next gets input out, which is the moment the
/// news stops being current; the label bar narrates it meanwhile, so
/// keystrokes never vanish without the wall admitting to it.
in_dropped: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// The keyboard re-cut the stripes: the pump owes the daemon a `.resize`.
/// A plain bool under `paint_mu`, not an atomic — an atomic could be
/// consumed apart from the rect it describes, and that pair is the point.
resize_pending: bool = false,
/// The `repaint_gen` the pump's current pass was taken under; the paint
/// sink refuses a paint once a relayout has moved past it. Under
/// `paint_mu` like the rect, and for the same reason: it describes it.
pass_gen: u64 = 0,
/// Focus moved onto this tile: its pump owes a `claimTerminal` (the
/// session's mouse modes and side channels). Doorbell-driven so the
/// claim runs on the pump thread, the one that owns the transport and
/// the Core.
claim_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
/// Focus moved off this tile: its pump owes a `releaseTerminal` to drop
/// the claim it took when it was focused. `.already_written`, because
/// the keyboard wrote the session's release under `paint_mu` before
/// doorbelling — the handover is ordered, not eventual.
release_pending: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
pub fn viewRows(t: *const Tile) u16 {
return t.rect.rows -| t.shared.labelRows();
}
pub fn viewCols(t: *const Tile) u16 {
return t.rect.cols;
}
};
/// The keyboard thread's whole wall, as one value. `liveTiles`/`livePresent`
/// name the prefix walk; the whole array walks into uninitialised slots.
/// Keyboard-thread only: a pump gets a `*Tile` and a `*Shared`, never this.
pub const Wall = struct {
alloc: std.mem.Allocator,
tiles: []Tile,
present: []bool,
/// The high-water mark of slots ever used, not the tile count: a reused
/// digit is already inside it, and it never falls.
live: *usize,
shared: *Shared,
hosts: []Host = &.{},
/// The slots ever used; the array past it is uninitialised.
pub fn liveTiles(self: Wall) []Tile {
return self.tiles[0..self.live.*];
}
pub fn livePresent(self: Wall) []bool {
return self.present[0..self.live.*];
}
};
/// Which tile's rectangle a zero-based terminal (row, col) falls in, or null
/// when none does — a click on a rail hits no tile and focus stays put.
/// Under `paint_mu`, which is what `Tile.rect` is written under.
pub fn rectHit(tiles: []Tile, present: []const bool, shared: *Shared, row: u16, col: u16) ?usize {
shared.paint_mu.lock();
defer shared.paint_mu.unlock();
for (tiles, present) |*t, p| {
if (!p) continue;
if (row >= t.rect.top and row < t.rect.top + t.rect.rows and
col >= t.rect.left and col < t.rect.left + t.rect.cols) return t.idx;
}
return null;
}
/// The keyboard loop's state: just the prefix filter.
const WallInput = struct {
prefix: interact.PrefixFilter = .{},
};
/// The next tile in `present` after `sel`, wrapping, or null when none is
/// left. `sel` itself is the answer when it is the only one present, which
/// is what makes a one-tile wall's `j` a no-op rather than a null.
pub fn stepPresent(present: []const bool, sel: usize, forward: bool) ?usize {
return stepWhere(present, null, sel, forward);
}
/// One lap of the ring from `sel` in `forward`'s direction, answering the
/// first index that qualifies. `tiles` narrows the answer to a tile whose
/// pump is still running; null takes any present tile. Exactly one lap, so
/// `sel` itself is the last index tried and never skipped.
fn stepWhere(present: []const bool, tiles: ?[]const Tile, sel: usize, forward: bool) ?usize {
const n = present.len;
if (n == 0 or sel >= n) return null;
var i = sel;
var seen: usize = 0;
while (seen < n) : (seen += 1) {
i = if (forward) (i + 1) % n else (i + n - 1) % n;
if (!present[i]) continue;
if (tiles) |ts| if (!ts[i].alive.load(.acquire)) continue;
return i;
}
return null;
}
/// How long a refusal leaves the second press armed — `Ctrl-\ X` on a tile,
/// `x` on a picker row. Long enough to read the
/// count and decide, short enough that the decision is about the session on
/// the screen and not one the user has since walked away from.
pub const end_arm_ms: i64 = 3000;
/// Which press this is. `Ctrl-\ x` removes a pane and asks the daemon
/// nothing (`removePane`); `Ctrl-\ X` is the end key, and this is the
/// intent its keypress posts to the tile's pump. The arming behind it is
/// written by the pump from the daemon's answer, through `onEndReply`.
pub fn intentForEnd(t: *const Tile, now: i64) client.SwitchIntent {
// The client never DECIDES to force: the daemon is the only side that
// knows who else is attached, so this only remembers being told to ask
// twice.
return if (now < t.end_armed_until.load(.acquire)) .end_force else .end;
}
/// An accepted end DISARMS: the window must not outlive its session.
pub fn onEndReply(t: *Tile, r: proto.EndReply, now: i64) void {
t.end_armed_until.store(if (r.accepted) 0 else now + end_arm_ms, .release);
}
/// `n`/`p`: the next TILE, or null when there is nowhere to go.
pub fn walkTiles(present: []const bool, sel: usize, forward: bool) ?usize {
// Over `present`, which is what the eyes are looking at: the wall is
// every host's sessions, and the daemon's session ring was only ever
// the local host's slice of it.
const to = stepPresent(present, sel, forward) orelse return null;
// Its own tile is not a move — a focus that "moves" to itself releases
// the terminal and re-claims it for nothing.
return if (to == sel) null else to;
}
/// Like `stepPresent` but prefers a tile whose pump is still alive. Falls
/// back to `stepPresent` when no live tile remains, so a wall of dead
/// narrators still has somewhere to put the cursor. `tiles` must outlive
/// the call; only `present` is used for the fallback.
fn stepLive(tiles: []const Tile, present: []const bool, sel: usize, forward: bool) ?usize {
return stepWhere(present, tiles, sel, forward) orelse stepPresent(present, sel, forward);
}
/// The state is remembered on the tile: the keyboard repaints this bar
/// with no frame, and only on a tty — a piped wall has no bar to paint.
pub fn paintLabel(t: *Tile, state: State) void {
t.shared.paint_mu.lock();
defer t.shared.paint_mu.unlock();
t.state = state;
if (t.shared.labelRows() != 0) paintLabelLocked(t);
}
/// The longest drift word: "daemon " + a capped version + " stale".
pub const drift_max = "daemon ".len + proto.sessions_meta_version_max + " stale".len;
/// The bar's version-drift word, composed from what the host's daemon said
/// about itself and what this client is. Says only what DIFFERS: a version
/// unequal to our own, a replaced image, both, or — the common case —
/// nothing at all. An unknown own version withholds the version half
/// rather than calling every daemon drifted; staleness needs no comparison
/// and is said regardless.
pub fn driftWord(
buf: *[drift_max]u8,
own_version: []const u8,
meta: ?proto.SessionsMeta,
) []const u8 {
const m = meta orelse return "";
// Re-capped here rather than trusted: the parser bounds what it yields,
// but this function's contract is `drift_max`, not its caller's manners.
const ver = m.version[0..@min(m.version.len, proto.sessions_meta_version_max)];
const differs = own_version.len != 0 and !std.mem.eql(u8, own_version, ver);
if (differs and m.stale)
return std.fmt.bufPrint(buf, "daemon {s} stale", .{ver}) catch "";
if (differs)
return std.fmt.bufPrint(buf, "daemon {s}", .{ver}) catch "";
if (m.stale) return "daemon stale";
return "";
}
/// Bounded by `cols` AND by `buf`: `cols` alone overran past 250 columns.
pub fn labelText(
buf: []u8,
cols: u16,
marker: []const u8,
label: []const u8,
status: []const u8,
) []const u8 {
// Everything the format puts around the label: the leading space, the
// marker, " [", the status word, "]".
const overhead = marker.len + status.len + 4;
// The state survives truncation, the label doesn't: a wall whose bars
// read `...scratchpad` instead of `[reconnecting]` narrates nothing.
const label_max = @min(@as(usize, cols) -| overhead -| 1, buf.len -| overhead);
const cut = label[0..@min(label.len, label_max)];
// Unreachable by the bound above, and an empty bar rather than
// undefined bytes if that bound is ever broken.
const text = std.fmt.bufPrint(buf, " {s}{s} [{s}]", .{ marker, cut, status }) catch buf[0..0];
return text[0..@min(text.len, cols)];
}
/// The label bar: inverse, full width, `N> LABEL [state]`, truncated at the
/// terminal edge. Caller holds `paint_mu`. Only called when `labelRows` is
/// nonzero — a piped wall owns every row and has no bar.
pub fn paintLabelLocked(t: *Tile) void {
if (popupOpen(t.shared)) return;
// ASCII, not an arrow glyph: this bar is byte-truncated, greppable in
// a capture, and must not depend on a font. The unfocused marker is
// the same width, so labels do not shift as the focus moves.
const arrow: []const u8 = if (t.shared.sel == t.idx) "> " else " ";
// Stable for the tile's whole life because `vanishTile` leaves a hole
// rather than compacting: a digit is never SHIFTED onto a neighbour,
// though a hole is what the next birth takes back (`freeSlot`). Past 9
// the number still prints, though no chord reaches it.
var marker_buf: [8]u8 = undefined;
const marker: []const u8 = std.fmt.bufPrint(&marker_buf, "{d}{s}", .{ t.idx + 1, arrow }) catch arrow;
// Keystrokes the mailbox had no room for are said where the eye already
// is, beside the state that explains them: input is only ever dropped
// by a pump that stopped reading, so the state word is the reason and
// this is the consequence. The host's drift word rides the same bracket
// — one place a bar says everything that is wrong, last because it is
// the least urgent thing in it.
var status_buf: [160]u8 = undefined;
const word = t.state.word();
const drift = t.drift[0..t.drift_len];
const dropped = t.in_dropped.load(.acquire);
const status: []const u8 = blk: {
if (dropped and drift.len != 0)
break :blk std.fmt.bufPrint(&status_buf, "{s}, input dropped, {s}", .{ word, drift }) catch word;
if (dropped)
break :blk std.fmt.bufPrint(&status_buf, "{s}, input dropped", .{word}) catch word;
if (drift.len != 0)
break :blk std.fmt.bufPrint(&status_buf, "{s}, {s}", .{ word, drift }) catch word;
break :blk word;
};
var text_buf: [256]u8 = undefined;
const shown = labelText(&text_buf, t.rect.cols, marker, t.r.label, status);
var out: [1024]u8 = undefined;
var fbs = std.io.fixedBufferStream(&out);
const w = fbs.writer();
w.print("\x1b[?2026h\x1b[{d};{d}H\x1b[7m{s}", .{ t.rect.top + 1, t.rect.left + 1, shown }) catch return;
// Padded with spaces rather than \x1b[K: erase-to-EOL fills with the
// background color, not the reverse-video attribute, on most
// terminals — the bar would end where the text does.
var i: usize = shown.len;
while (i < t.rect.cols) : (i += 1) w.writeByte(' ') catch break;
w.writeAll("\x1b[0m\x1b[?2026l") catch return;
proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {};
}
// The bars of the tiles whose pump has ended. Caller holds `paint_mu`.
// A dead pump answers no doorbell, so the keyboard is the only thread that
// can move its focus marker. `removed` rather than `present`, because a focus
// move has no `present` slice and a vanished tile's rect is a neighbour's.
pub fn paintDeadBarsLocked(tiles: []Tile) void {
for (tiles) |*t| {
if (!t.alive.load(.acquire) and !t.removed.load(.acquire)) paintLabelLocked(t);
}
}
/// A status line in ONE tile's own corner, bounded by its rect: a notice
/// wider than a left-hand pane must not cross the rail into its neighbour,
/// and one right-aligned to the SCREEN lands in whichever pane owns that
/// corner. The only owner of a per-tile banner, for exactly that reason.
pub fn tileBanner(t: *Tile, text: []const u8) void {
if (!t.shared.is_tty) return;
// The tail, so the cursor end of a long spelling is what is on screen.
// Bounded by the paint's own cap as well as the rect: a label past it
// is not truncated by `paintBanner`, it is dropped whole.
const room = @min(@as(usize, t.rect.cols), paint.banner_label_max);
const shown = if (text.len > room) text[text.len - room ..] else text;
t.shared.paint_mu.lock();
defer t.shared.paint_mu.unlock();
// Under the lock, for `tilePaintBegin`'s reason: read before it, a
// banner that lost the race for `paint_mu` lands on a box row, and
// `picker_frame` suppresses the repaint that would repair it.
if (popupOpen(t.shared)) return;
paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.labelRows(), t.rect.left);
}
/// Never blocks and never reports: a full pipe is a bell already ringing.
pub fn ring(t: *const Tile) void {
_ = std.posix.write(t.wake_w, "\x00") catch {};
}
/// A CHORD's doorbell: false when nobody is listening.
fn ringLive(t: *const Tile) bool {
// An ended pump polls nothing, so its bell is a byte into a pipe with
// no reader and the chord would die silently. The WALL's own bells
// still ring unconditionally — waking a pump so it can RETURN is the
// one thing a dead one does not need.
if (!t.alive.load(.acquire)) return false;
ring(t);
return true;
}
// A tile's wake pipe is never closed: the fd NUMBER would be recycled, and a
// late bell would put a stray byte into somebody's live connection instead of
// getting EBADF. Two fds per tile — the process exit is the cheaper owner.
/// `ring` in the other direction: never blocks, never reports.
pub fn ringKeyboard(shared: *const Shared) void {
_ = std.posix.write(shared.kb_w, "\x00") catch {};
}
/// The same bell behind `askpass.Hooks`' runtime pair.
fn ringKeyboardCtx(ctx: *anyopaque) void {
ringKeyboard(@as(*const Shared, @ptrCast(@alignCast(ctx))));
}
/// Both ends of both bells are non-blocking, which is what makes this safe
/// on a pipe nobody has rung.
pub fn drainBell(fd: std.posix.fd_t) void {
var sink: [64]u8 = undefined;
while (std.posix.read(fd, &sink)) |n| {
if (n < sink.len) break;
} else |_| {}
}
/// Leave a sentence for whichever tile owns the terminal next. Keyboard
/// thread only, and only for a focus it is about to move — the pump that
/// lands there paints it as a banner on its claim.
pub fn setNotice(shared: *Shared, text: []const u8) void {
shared.paint_mu.lock();
defer shared.paint_mu.unlock();
const n = @min(text.len, shared.notice.len);
@memcpy(shared.notice[0..n], text[0..n]);
shared.notice_len = n;
}
/// A standing condition, into an EMPTY slot only: one notice, re-derived
/// every second per host, would erase a sentence the user just earned.
pub fn setNoticeIdle(shared: *Shared, text: []const u8) void {
shared.paint_mu.lock();
// Dropped between the two: both writers are the keyboard thread, so
// nothing can take the slot in the gap.
const held = shared.notice_len != 0;
shared.paint_mu.unlock();
if (!held) setNotice(shared, text);
}
/// What a gone pane can do, said where the user is looking. Under the
/// 96-byte notice buffer; `setNoticeIdle` truncates silently past it.
pub const gone_notice: []const u8 = "[session ended - Enter restarts it, x closes]";
/// The same, for an unreachable pane — the one the wall was silent about, so
/// the user pressed Enter (the gone reflex) and nothing answered. It says
/// the host is retrying on its own AND that Enter hurries it, because both
/// are true: the shared poller retries every cycle, and Enter pokes it now.
pub const unreachable_notice: []const u8 = "[host unreachable, retrying - Enter retries now, x closes]";
/// Take it, once.
pub fn takeNotice(shared: *Shared, out: []u8) []const u8 {
// Copied out because the caller paints it after releasing the lock:
// `Core.banner` takes `paint_mu` itself, through the sink, and the
// sink is not reentrant.
shared.paint_mu.lock();
defer shared.paint_mu.unlock();
return takeNoticeLocked(shared, out);
}
/// Called ONLY from the keyboard loop and ONLY while `t` is focused: that
/// restriction IS "an unfocused tile claims no terminal modes". A chunk that
/// does not fit is dropped WHOLE — a partial copy is a command nobody typed.
pub fn sendKeys(t: *Tile, keys: []const u8) void {
{
t.in_mu.lock();
defer t.in_mu.unlock();
if (keys.len > t.in.len - t.in_len) {
// Narrated rather than silent: input vanishing with no
// explanation is the worst version of this. Atomic rather than
// guarded by `in_mu`, so the label bar can read it under
// `paint_mu` without the two locks ever having to nest.
t.in_dropped.store(true, .release);
} else {
@memcpy(t.in[t.in_len..][0..keys.len], keys);
t.in_len += keys.len;
}
}
ring(t);
}
/// Move the focus to tile `next`. Client-local: it decides which pump owns
/// the terminal's modes, not which session the daemon hears. The outgoing
/// tile's `session_release` is written HERE under `paint_mu`, because two
/// pumps are two threads and A's release races B's claim.
pub fn setFocus(tiles: []Tile, shared: *Shared, next: usize) void {
const prev = shared.sel;
// Scoped rather than deferred to the end of the function: the notice at
// the tail goes through `setNoticeIdle`, which takes `paint_mu` itself,
// and the mutex is not reentrant.
var landed: State = .waiting;
{
shared.paint_mu.lock();
defer shared.paint_mu.unlock();
moveFocusLocked(tiles, shared, prev, next);
// Read HERE because `state` is a `paint_mu` field a pump writes
// through `paintLabel`, and the keyboard is not its owner.
if (next < tiles.len) landed = tiles[next].state;
}
// The pane's bar names the state; this says what to do about it. Idle
// only: a refusal or an exit sentence already on screen outranks a hint.
switch (landed) {
.gone => setNoticeIdle(shared, gone_notice),
.@"unreachable" => setNoticeIdle(shared, unreachable_notice),
else => {},
}
}
/// The focus move itself. Caller holds `paint_mu`.
fn moveFocusLocked(tiles: []Tile, shared: *Shared, prev: usize, next: usize) void {
// The outgoing session's modes come off HERE, ahead of the doorbell that
// lets the next pump claim — and only when there IS a terminal to change.
if (shared.is_tty and prev != next and prev < tiles.len and tiles[prev].alive.load(.acquire)) {
proto.writeAllFd(shared.out_fd, interact.session_release) catch {};
// The wall's own click capture goes straight back on, by THIS hand:
// the incoming tile's claim used to be the re-arm, and a pump parked
// dialling a dead host never claims — one click on an unreachable
// pane then deafened the mouse for the whole wall. See
// `interact.wall_mouse_capture`.
proto.writeAllFd(shared.out_fd, interact.wall_mouse_capture) catch {};
tiles[prev].release_pending.store(true, .release);
ring(&tiles[prev]);
}
shared.sel = next;
if (next < tiles.len) {
tiles[next].claim_pending.store(true, .release);
ring(&tiles[next]);
}
// Repaint the label bars: the focus marker moved. One bump tells every
// pump to redraw its bar; the doorbells above make the two involved
// tiles immediate, and the rest follow on their next poll timeout.
_ = shared.repaint_gen.fetchAdd(1, .release);
for (tiles) |*t| ring(t);
// Only where there IS a bar: a piped wall's `labelRows` is zero, and
// a bar painted there lands on a content row the tile that owns the
// screen has already written.
if (shared.labelRows() != 0) paintDeadBarsLocked(tiles);
}
/// Re-cut before the move: `setFocus` releases the outgoing session
/// and needs the true previous focus.
pub fn focusAnswer(w: Wall, recut: bool, to: usize) void {
if (recut) wall_layout.relayout(w, w.shared.sel);
setFocus(w.liveTiles(), w.shared, to);
// Fullscreen re-flattens with the new focus so the full rect follows.
if (w.shared.fullscreen) wall_layout.relayout(w, w.shared.sel);
}
pub const no_live_here = "[no live session here - Ctrl-\\ s picks a host]";
/// TAKEN, not read: an empty wall has no pump to paint a notice as a
/// banner, and every relayout would repaint a sentence left on this line.
pub fn emptyWallHint(shared: *Shared) []const u8 {
if (shared.notice_len == 0) return "Ctrl-\\ s to pick a host - Ctrl-\\ d to leave";
const said = shared.notice[0..shared.notice_len];
shared.notice_len = 0;
return said;
}
/// The whole screen an empty wall gets: one line, at the top, saying the
/// wall is empty and the way out. A blank terminal with no cursor reads as
/// hung, and the last `x` is precisely when the user needs to be told that
/// what they see is the answer and not a crash.
pub fn paintEmptyWallLocked(shared: *Shared) void {
if (!shared.is_tty) return;
var text_buf: [256]u8 = undefined;
const shown = labelText(
&text_buf,
shared.size.cols,
"",
"the wall is empty",
emptyWallHint(shared),
);
var out: [512]u8 = undefined;
var fbs = std.io.fixedBufferStream(&out);
fbs.writer().print("\x1b[1;1H{s}\x1b[?25h", .{shown}) catch return;
proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {};
}
/// A tile leaves the wall: off the `present` roll, off the tree, and its pump
/// told to return. The ONE owner — chord and poll diff both come here, so the
/// focus hand-off is written once. `to` overrides where the focus goes.
pub fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to: ?usize) void {
present[i] = false;
tiles[i].removed.store(true, .release);
ring(&tiles[i]);
shared.tree.remove(@intCast(i));
// Through `setFocus`, never by writing `sel`: a focus that arrives
// without a claim leaves the new tile's pump holding no terminal — no
// mouse modes, no prediction — until the user moves focus away and back.
if (shared.sel == i) {
// Nothing left to step to is not a reason to focus the tile that
// just went: `sel` stays where it is, and the caller puts it on a
// real tile as soon as there is one (`applyHostList`). An empty
// wall has none, and paints its own line instead.
if (to orelse stepPresent(present, i, true)) |next| setFocus(tiles, shared, next);
}
}
/// `Ctrl-\ x`: this pane leaves THIS wall. The session is the daemon's and
/// keeps running for whoever else holds it; ending one is the picker's
/// job, beside the count of who else is there. The pump is told to say
/// goodbye (`detach_req`): it writes `.detach` on its way out — the last
/// thing `pumpTile` does past its loop — so the daemon frees the slot on a
/// goodbye rather than at a timeout, and the transport close behind it is
/// the fallback for a pump that ended some other way. Then the tile is
/// vanished and the layout written without it.
///
/// The sentence is set BEFORE the vanish, and the order is the whole of
/// whether the user ever reads it. `vanishTile` hands the focus on, which
/// arms the incoming pump's claim, and a claim's first act is `takeNotice`:
/// set afterwards, the notice loses that race about one press in four
/// (measured 2026-09-02) and then waits for a focus move the user has no
/// reason to make. Set first, the claim that the vanish arms is the claim
/// that paints it.
pub fn removePane(w: Wall, z: usize) void {
if (z >= w.live.* or !w.present[z]) return;
const t = &w.tiles[z];
t.detach_req.store(true, .release);
ring(t);
setNotice(w.shared, "[pane removed - the session is still on its daemon]");
vanishTile(w.liveTiles(), w.livePresent(), w.shared, z, null);
wall_layout.relayout(w, w.shared.sel);
wall_layout.persist(w);
}
/// A stripe of the shell's own session paints into the grid it reads.
pub fn showsSelf(
target: client.Target,
name: []const u8,
env_sock: ?[]const u8,
env_session: ?[]const u8,
) bool {
// A unix socket path only: a host or quic:// target is a different
// daemon whatever its sessions are called.
const sock = switch (target) {
.sock => |p| p,
else => return false,
};
const es = env_sock orelse return false;
const en = env_session orelse return false;
// Emptied counts as unset: `MUX_SESSION=` is how a shell overrides an
// exported variable it cannot unset, and the refusal names unsetting as
// the way out — both spellings of that have to work.
if (es.len == 0 or en.len == 0) return false;
// Both halves, so session 0 attaching to session 1 of one daemon works.
// String equality: this guards a mistake, not a security boundary.
return std.mem.eql(u8, es, sock) and std.mem.eql(u8, en, proto.resolveName(name));
}
/// Two tiles on the same daemon, as SPELLED. Identity dedup would need an
/// endpoint handshake the spec refuses, so one session reached two ways is
/// two tiles; the ring chords only ask their own daemon and never care.
fn sameTarget(a: client.Target, b: client.Target) bool {
if (std.meta.activeTag(a) != std.meta.activeTag(b)) return false;
return switch (a) {
.sock => |p| std.mem.eql(u8, p, b.sock),
.via => |c| std.mem.eql(u8, c, b.via),
.quic => |q| std.mem.eql(u8, q.host_port, b.quic.host_port),
.hand => |h| std.mem.eql(u8, h.host, b.hand.host),
};
}
pub fn presentCount(present: []const bool) usize {
var n: usize = 0;
for (present) |p| {
if (p) n += 1;
}
return n;
}
/// The lowest slot a new tile may take back: off the wall AND its pump
/// returned. `Tile.pump_done` is why both halves are needed.
pub fn freeSlot(tiles: []const Tile, present: []const bool) ?usize {
for (tiles, present, 0..) |*t, p, i| {
if (!p and t.pump_done.load(.acquire)) return i;
}
return null;
}
/// Whether a slot arrives with a doorbell already in it. `kept` is a slot a
/// vanished tile left behind: the pipe is never closed, so digging a fresh
/// one per reuse would leak an fd pair per birth. Nothing else in a reused
/// slot survives — `initTile` overwrites the whole tile.
const Doorbell = enum { fresh, kept };
/// One tile, with the doorbell its pump polls. Split out of `run` because
/// tiles are born in two places now — at startup, and whenever a focus chord
/// or the saved wall adds one.
fn initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize, bell: Doorbell) !void {
// The doorbell, before the pump that polls it exists. Non-blocking both
// ends: neither side may wedge the other. A kept pipe may still hold the
// byte that rang the departing pump; the bytes carry nothing.
const wake: [2]std.posix.fd_t = switch (bell) {
.fresh => try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }),
.kept => .{ t.wake_r, t.wake_w },
};
t.* = .{
.r = r,
.rect = s,
.shared = shared,
.idx = idx,
.wake_r = wake[0],
.wake_w = wake[1],
};
}
/// The one maker of a pending tile: a slot whose rect the seed already
/// cut, wearing the sidecar's names, that nothing will dial. `pump_done`
/// is true because there is no thread to wait for; `bindTile` undoes it.
pub fn seedTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize, host: usize) !void {
try initTile(t, r, s, shared, idx, .fresh);
t.pending = true;
t.host = host;
t.state = .waiting;
t.creates = false;
t.alive.store(false, .release);
t.pump_done.store(true, .release);
}
pub fn spawnPump(t: *Tile) void {
// The one arming point is also the one refusal: a remembered shape
// never dials, so the sidecar cannot re-create a session.
if (t.pending) return;
const th = std.Thread.spawn(.{}, wall_pump.pumpTile, .{t}) catch {
// A tile with no thread is one nothing will ever paint. Marked here
// so the keyboard paints its bar; `.connecting` would be a lie. The
// entry tile's link is already open and nobody is left to adopt it.
if (t.pre) |*pre| {
pre.close();
t.pre = null;
}
t.state = .refused;
// Not `.refused`: nothing refused anything. `endAction` turns that
// into "attach refused (session full?)", which would blame a daemon
// that was never asked.
t.end.store(@intFromEnum(EndReason.no_thread), .release);
t.alive.store(false, .release);
ringKeyboard(t.shared);
// No thread was ever going to read this slot, so its digit is free
// the moment the tile leaves the wall — without this a spawn that
// failed would hold a slot nothing can ever reuse.
t.pump_done.store(true, .release);
return;
};
th.detach();
}
/// Where a focus chord's answer takes the focus.
const FocusTo = union(enum) {
/// Onto this tile: one that was already on the wall (a hot replica and
/// a local repaint — zero round trips) or one just added for it.
moved: usize,
/// The wall cannot hold another tile. Said rather than silently done,
/// because the alternative — a roaming connection that claims full size
/// without earning a tile — is a rule this spec states elsewhere.
full,
/// Nowhere to go; the focus stays put.
stay,
};
/// How a chord-born tile enters the tree: `beside_focus` is the `c` chord's
/// sibling-after-focus (insert), `right_of` / `below` are the split chords.
const Place = enum { beside_focus, right_of, below };
/// One row of the birth table: what a caller owes a new tile.
const Birth = struct {
r: Resolved,
from: usize,
place: Place,
creates: bool,
// Where a REFUSED attach hands the focus back; null for a tile no
// tile made (the fold's).
born_from: ?usize,
// Whether the wall stands without it: true for a birth the PICKER made,
// which is a session starting on a wall, not the wall itself starting.
keeps_wall: bool = false,
// Which host owns it. A chord-born tile inherits the focus's. Grading
// is per host (`wall_host.ownedBy`), so a pane carrying the wrong host
// or none is one no list ever binds, confirms or calls gone.
host: ?usize = null,
// Whether `r.session` is borrowed and the tile's own copies are owed.
// FALSE is an ownership claim, not an optimisation: the tile takes
// `label` and `session` verbatim and a later birth frees both.
borrowed: bool = false,
};
/// The pending→live transition, in the one order that is safe: a saved pane
/// stops being a drawing and becomes a session a pump is dialling. `creates`
/// asks the daemon to MAKE the session (a revive); false attaches to one the
/// host already listed (a bind). The caller spawns the pump after.
fn wakePending(t: *Tile, creates: bool) void {
t.shared.paint_mu.lock();
t.pending = false;
if (creates) t.creates = true;
t.state = .connecting;
t.shared.paint_mu.unlock();
t.end_seen = false;
// The focus may already be sitting on this pane — the saved focus a bind
// lands under, or the pane whose Enter asked for the revive — and no
// `setFocus` moves onto it, so the claim is what reaches the terminal.
if (t.idx == t.shared.sel) t.claim_pending.store(true, .release);
// False must land before the thread exists: the pump only ever stores
// true, and `freeSlot` reads it as "no thread still holds this slot".
t.pump_done.store(false, .release);
t.alive.store(true, .release);
}
/// How a pending pane comes to life — the two axes every wake differs on,
/// and nothing else. `creates`: the daemon MAKES the session (a nonzero
/// attach size) rather than only attaching to one it already lists.
/// `asked`: the dial STARTS the daemon (`mux d endpoint --start`) rather
/// than reading what is already there — the field the entry attach and a
/// picker birth set, worn on a `.hand` target. Every road onto a pending
/// pane — a poll bind, a gone-pane Enter, an unreachable-pane Enter — is one
/// of these three combos, so they share one primitive and diverge only in
/// the struct.
const ReviveOpts = struct { creates: bool, asked: bool };
/// Set the dial's start-the-daemon flag where the target can carry one. A
/// `.sock`/`.quic`/`.via` target has no daemon to start over ssh, so the
/// flag has nowhere to live and the ask is a no-op — a local daemon that is
/// down is `mux d start`, not a wall pane's to boot.
fn armAsked(t: *Tile, asked: bool) void {
if (t.r.target == .hand) t.r.target.hand.asked = asked;
}
/// The state half of a wake, split from the thread so a test can pin the
/// transitions without racing a pump.
pub fn armPane(t: *Tile, opts: ReviveOpts) void {
armAsked(t, opts.asked);
wakePending(t, opts.creates);
}
/// The one wake: arm the pane, then give it its pump. Every caller names its
/// intent in the struct — a bind attaches ({false,false}), a gone-pane Enter
/// re-creates on a live host ({true,false}), an unreachable-pane Enter starts
/// the daemon first and then re-creates ({true,true}). No tree edit and no
/// flatten: waking re-cuts nothing, which is the promise a seeded rect keeps.
fn revivePane(t: *Tile, opts: ReviveOpts) void {
armPane(t, opts);
spawnPump(t);
}
/// A pending pane the host's own list confirmed: wake it in place, attaching
/// to the session the poll just saw — never creating, never starting.
pub fn bindTile(w: Wall, i: usize) void {
revivePane(&w.tiles[i], .{ .creates = false, .asked = false });
}
/// The one door for bytes aimed at the focused tile. A pending pane has no
/// pump, so no byte could reach a session — eaten here rather than queued
/// into `in` where they would greet the NEXT session this tile carries.
/// Enter is the pane's one verb: it wakes the pane through `revivePane`, and
/// the only thing that differs by WHY the pane was pending is whether the
/// daemon must be started first — a gone pane's host is up, an unreachable
/// pane's may be down.
pub fn keysToFocused(t: *Tile, keys: []const u8) void {
if (t.pending and (t.state == .gone or t.state == .@"unreachable")) {
if (std.mem.indexOfAny(u8, keys, "\r\n") != null)
revivePane(t, .{ .creates = true, .asked = t.state == .@"unreachable" });
return;
}
sendKeys(t, keys);
}
/// Every road onto a running wall — chord, fold, prompt — one body.
pub fn birthTile(w: Wall, b: Birth) ?usize {
return birthTileOrRefuse(w, b) catch null;
}
fn birthTileOrRefuse(w: Wall, b: Birth) !usize {
// The lowest digit a departed tile left behind, before a new one:
// create 1 2 3, end 2, create — and the wall says 2, not 4. Reuse, not
// renumbering: the tiles that stayed keep the digit their user learned.
const reuse = freeSlot(w.liveTiles(), w.livePresent());
if (reuse == null and w.live.* >= max_tiles) return error.WallFull;
// "Does it fit" has ONE owner, and it is the tree: insert, flatten,
// and undo the insert when flatten refuses. Row arithmetic here
// capped every terminal at rows/3 panes however wide, because it
// cannot see that a `.beside` cut spends columns.
const at = reuse orelse w.live.*;
switch (b.place) {
.beside_focus => if (w.shared.tree.root == null)
// A wall whose tiles all arrive from a host's list starts with
// no tree at all; `insert` has no leaf to sit beside.
try w.shared.tree.addFirst(@intCast(at))
else
try w.shared.tree.insert(@intCast(b.from), @intCast(at)),
.right_of => try w.shared.tree.splitRight(@intCast(b.from), @intCast(at)),
.below => try w.shared.tree.splitBelow(@intCast(b.from), @intCast(at)),
}
// Past the insert, so every refusal below puts the tree back exactly
// as the caller found it.
errdefer w.shared.tree.remove(@intCast(at));
const flat = try w.shared.tree.flatten(
w.alloc,
w.shared.size.rows,
w.shared.size.cols,
wall_layout.floorsOf(w.shared),
null,
);
defer flat.deinit(w.alloc);
const new_rect = flat.rectOf(@intCast(at)) orelse return error.NoRect;
// The real rect, not a placeholder: the daemon refuses creates under
// `min_session_rows`, and a 2-row rect on a live session is destructive
// under latest-wins.
// Past every refusal: the tile is the wall's now, so the copies a pump
// will hold for its whole life are worth making. Made BEFORE the slot
// is overwritten, so the last thing that can fail here still fails
// against a slot that is exactly as the caller found it.
var r = b.r;
if (b.borrowed) {
const session = try w.alloc.dupe(u8, b.r.session);
errdefer w.alloc.free(session);
r.session = session;
r.label = try tileLabel(w.alloc, b.r.target, session);
}
// Only the copies THIS call made: a caller that owns them frees its own.
errdefer if (b.borrowed) {
w.alloc.free(r.session);
w.alloc.free(r.label);
};
// The departed tile's copies go with its digit. Every tile owns these
// two — `run` dupes even the entry tile's session for this — so a
// reused slot that kept them would leak one label and one name per
// birth for the wall's whole life.
if (reuse != null) {
w.alloc.free(w.tiles[at].r.session);
w.alloc.free(w.tiles[at].r.label);
}
// Only a `.fresh` doorbell can fail, and it fails before the slot is
// written, so the errdefers above are the whole unwind.
try initTile(&w.tiles[at], r, new_rect, w.shared, at, if (reuse == null) .fresh else .kept);
// The caller's row of the birth table, and the whole of what separates
// the roads: a chord row inherits its target and agent and CREATES, a
// poll row joins a session the daemon already has. The pump spawn stays
// the caller's.
w.tiles[at].creates = b.creates;
w.tiles[at].born_from = b.born_from;
w.tiles[at].keeps_wall = b.keeps_wall;
w.tiles[at].host = b.host;
w.present[at] = true;
// `live` is the high-water mark of slots ever used, not the tile count:
// a reused digit is already inside it, and growing here would walk the
// keyboard's `liveTiles` walk off the end of the array.
if (reuse == null) w.live.* += 1;
return at;
}
/// A sibling is focused if it has a tile and GETS one if not — otherwise a
/// tile labelled S would paint T.
fn addSessionTile(w: Wall, from: usize, name: []const u8, place: Place) FocusTo {
// The parent's ask does not descend. A chord is a session on a daemon
// this wall is already attached to, so there is nothing here to start;
// inheriting the bit would hand every descendant of a picker-born tile
// a permission nobody asked for, for the rest of its life.
var target = w.tiles[from].r.target;
if (target == .hand) target.hand.asked = false;
const want = proto.resolveName(name);
for (w.liveTiles(), w.livePresent(), 0..) |*t, p, i| {
if (!p) continue;
if (!sameTarget(t.r.target, target)) continue;
if (std.mem.eql(u8, proto.resolveName(t.r.session), want)) return .{ .moved = i };
}
const at = birthTile(w, .{
// The offer is inherited: same target, so `-A` exposes nothing new,
// and a chord-made tile has no command line to spell the flag on.
// `want` borrows the caller's name — see `Birth.borrowed`.
.r = .{ .target = target, .label = "", .session = want, .agent = w.tiles[from].r.agent },
.from = from,
.place = place,
.creates = true,
.born_from = from,
.host = w.tiles[from].host,
.borrowed = true,
}) orelse return .full;
spawnPump(&w.tiles[at]);
return .{ .moved = at };
}
/// A refusal reaches the eyes that earned it: a live tile shows the notice
/// on the re-claim, a dead tile is never claimed, so the keyboard paints it
/// there and TAKES it — left in `shared` it surfaces on a later claim.
fn showRefusal(tiles: []Tile, shared: *Shared, z: usize) void {
if (tiles[z].alive.load(.acquire)) {
setFocus(tiles, shared, z);
return;
}
var buf: [96]u8 = undefined;
const text = takeNotice(shared, &buf);
if (text.len > 0) tileBanner(&tiles[z], text);
}
/// What the closing picker owes the wall it hands back: the retry for a
/// claim the popup refused, and — when the popup left a sentence behind —
/// the focus move that is the only thing which makes a pump take it.
pub fn closeNotice(tiles: []Tile, present: []const bool, shared: *Shared) void {
const z = shared.sel;
if (z >= tiles.len or !present[z]) return;
shared.paint_mu.lock();
const pending = shared.notice_len != 0;
shared.paint_mu.unlock();
// A forget's sentence names a host that owns no tile here, so nothing
// else on this path will ever move the focus and arm a claim: left to
// the bare ring, `[hosts file not updated: ...]` waits for the user's
// next focus move to be read, which may be never.
if (pending) showRefusal(tiles, shared, z) else _ = ringLive(&tiles[z]);
}
/// How a tile spells itself on the wall — the form that keys the layout
/// sidecar's leaves. `--via` has no form in that grammar, so it gets a label
/// that is honest on a bar and is not a spelling.
pub fn tileLabel(alloc: std.mem.Allocator, target: client.Target, name: []const u8) ![]const u8 {
// `spellingCap` counts a `--via` operand as ZERO — the grammar has no
// place to put one. A label does, and the command has no length bound.
const cap = switch (target) {
.via => |cmd| "--via ".len + cmd.len + 1 + proto.session_name_max,
else => client.spellingCap(target),
};
const buf = try alloc.alloc(u8, cap);
const text = if (client.wallSpelling(buf, target, name)) |s| s else |_| switch (target) {
.via => |cmd| std.fmt.bufPrint(buf, "--via {s}#{s}", .{ cmd, name }) catch buf[0..0],
// Every other target has a wall spelling and the buffer is sized
// for it, so this is unreachable in practice; an empty label beats
// a failed attach for a bar nobody has looked at yet.
else => buf[0..0],
};
// The label IS its allocation, never a prefix of one: `cap` is an upper
// bound and a caller that frees a sub-slice of it frees the wrong size.
// A shrink cannot move memory in any allocator this runs under, so the
// `realloc` arm is the contract's fallback rather than a real path.
return if (alloc.resize(buf, text.len)) buf[0..text.len] else try alloc.realloc(buf, text.len);
}
/// What a pump's ending means for the wall. Only the last present tile
/// ending can end the program: a tile that goes quiet while others remain
/// narrates itself and the wall goes on, which is what the dead-tile paint is for.
pub const EndAction = union(enum) {
/// The wall has other tiles: the focus moves to the next present one.
/// Its label already says what became of the ended tile.
refocus: usize,
/// Nothing left to look at. mux ends here.
finish: struct { code: u8, msg: ?[]const u8 },
/// A tile with nothing left to say leaves no trace; `msg` is the
/// refusal's sentence, an exit says nothing. `back` is null when there
/// is no tile left to hand the focus to — the wall stays up, empty.
vanish: struct { back: ?usize, msg: ?[]const u8 },
};
/// One sentence in both places it is said: on stderr as `mux: ...`, and in
/// the brackets a banner wears.
pub fn noticeText(buf: *[128]u8, msg: []const u8) []const u8 {
const prefix = "mux: ";
const bare = if (std.mem.startsWith(u8, msg, prefix)) msg[prefix.len..] else msg;
return std.fmt.bufPrint(buf, "[{s}]", .{bare}) catch bare;
}
/// This run stops writing the layout file, for good, and says why. Nulling
/// the path is the whole mechanism: `wall_layout.persist` reads it before
/// every save, so one call here covers every later change to the wall. The
/// three callers are the three ways a run can end up showing something the
/// file does not describe — a `--via` wall, a file this wall refused, and a
/// file it could only seat part of.
fn stopSaving(shared: *Shared, alloc: std.mem.Allocator, why: []const u8) void {
if (shared.layout_path) |p| alloc.free(p);
shared.layout_path = null;
setNotice(shared, why);
}
/// The refusal, cut to what a notice holds. stderr already carried the whole
/// line before the alternate screen opened; this is the reminder that
/// survives onto the wall, so the head is what matters and the tail of a
/// long spelling is the part to lose.
pub fn refusalNotice(buf: *[96]u8, line: []const u8) []const u8 {
const head = "[layout not saved: the layout file was refused - fix ";
const room = buf.len - head.len - 1;
const n = @min(line.len, room);
return std.fmt.bufPrint(buf, "{s}{s}]", .{ head, line[0..n] }) catch
"[layout not saved: the layout file was refused]";
}
/// Closed `stdin` ends mux: a wall nobody types at is nowhere to leave a user.
pub fn endAction(
tiles: []Tile,
present: []const bool,
live: usize,
ended: usize,
stdin_open: bool,
is_tty: bool,
) EndAction {
const t = &tiles[ended];
const reason: EndReason = @enumFromInt(t.end.load(.acquire));
// A chord-born tile that never got its session leaves no trace. The
// sentence names the state: a daemon that answered and said no may free a
// slot, while one that never answered has to be started.
const gone_msg: ?[]const u8 = switch (reason) {
// Whatever the tile had been: the user REFUSED this dial, and a
// wall that retried anyway would ask the same question every two
// seconds for as long as it stayed up.
.declined => "mux: prompt declined",
.refused => "mux: cannot create a new session (daemon full?)",
.lost => if (t.ever_up.load(.acquire))
null
else
"mux: cannot create a new session (daemon unreachable)",
else => null,
};
if (gone_msg) |msg| {
if (t.born_from) |back| {
if (back < live and present[back])
return .{ .vanish = .{ .back = back, .msg = msg } };
}
}
// A clean exit is noise once over: the tile leaves and the wall re-cuts,
// but only while somebody is left to steer it. On a TERMINAL the LAST
// tile goes the same way and the wall stays up empty; a piped `mux` is a
// wall of one and still exits with the shell's code, which scripts read.
if (reason == .exited and stdin_open and (is_tty or presentCount(present[0..live]) > 1)) {
// Null rather than the tile that just went: `stepLive` falls back
// to the selection itself when there is nothing else present, and
// handing the focus to a vanished tile is how an empty wall would
// end up with a pump-less claim.
const back: ?usize = if (presentCount(present[0..live]) > 1)
stepLive(tiles[0..live], present[0..live], ended, true)
else
null;
return .{ .vanish = .{ .back = back, .msg = null } };
}
// A lost link may heal, and a refusal narrates: both keep
// their rect and their bar, and the focus steps on. Same liveness
// preference: a live neighbour can take the keyboard, a dead one
// cannot.
if (stdin_open and presentCount(present[0..live]) > 1)
return .{ .refocus = stepLive(tiles[0..live], present[0..live], ended, true) orelse ended };
const done: EndAction = .{
.finish = switch (reason) {
// The shell's own code, which is the whole point of the single-tile
// case: `mux` is still what you put in a script.
.exited => .{ .code = t.code.load(.acquire), .msg = null },
.taken => .{ .code = 0, .msg = "mux: detached (another client attached)" },
.refused => .{
.code = 1,
.msg = "mux: attach refused or no state received (session full?)",
},
// A transport that died before any session carried none. The
// sentence separates a `--via` command that never ran from a link
// that was up and went — `client.lostMsg` is where that distinction
// is kept.
.lost => .{ .code = 1, .msg = client.lostMsg(t.r.target, 0) },
// A pump that could not start at all: no Core, or a dial the quit
// interrupted. There is nothing true to add to the exit code.
.none => .{ .code = 1, .msg = null },
// ...and the one cause that IS knowable, which no daemon had a
// part in.
.no_thread => .{ .code = 1, .msg = "mux: could not start a thread for this session" },
// Said in the user's own terms: they pressed Esc at a password
// box, and no ssh diagnostic describes that better than they do.
.declined => .{ .code = 1, .msg = "mux: prompt declined" },
},
};
// A session the PICKER started ends as a session ends: the sentence goes
// on the empty-wall line, and the wall stays for the next Enter. Off a
// terminal there is no wall to leave up. Falling PAST this branch is the
// entry tile's road, where the ending is the program's — `Tile.keeps_wall`
// is why no other field can tell the two apart.
if (t.keeps_wall and is_tty and stdin_open)
return .{ .vanish = .{ .back = null, .msg = gone_msg orelse done.finish.msg } };
return done;
}
/// Newly dead tiles, drained one per pass. Returns the first that is focused
/// or `.exited` — both need the keyboard to vanish them; the rest narrated on
/// their own bars and are marked seen here.
pub fn endedTile(tiles: []Tile, present: []const bool, shared: *Shared) ?usize {
const z = shared.sel;
var hit: ?usize = null;
for (tiles, present, 0..) |*t, p, i| {
// A pending pane is `!alive` because no pump ran, not because one
// ended: reading it as dead would exit mux on a resumed wall.
if (!p or t.end_seen or t.pending or t.alive.load(.acquire)) continue;
const reason: EndReason = @enumFromInt(t.end.load(.acquire));
// A tile that only narrates (lost / refused / taken) needs no
// keyboard action: its pump painted its bar before it died.
if (i != z and reason != .exited) {
t.end_seen = true;
continue;
}
if (hit == null) {
t.end_seen = true;
hit = i;
}
}
return hit;
}
/// The bare-Ctrl-\ abort is gated on exactly this: a tile whose pump ENDED is
/// not waiting, so its `Ctrl-\ w` is the chord it looks like. The state comes
/// back too: nothing was detached from a session that never came up.
fn awaitingSession(t: *Tile) ?State {
if (!t.alive.load(.acquire)) return null;
t.shared.paint_mu.lock();
defer t.shared.paint_mu.unlock();
return switch (t.state) {
.connecting, .reconnecting => t.state,
else => null,
};
}
/// Not tidiness: the daemon frees the slot when it reads the frame, and a
/// `mux` typed straight after a `Ctrl-\ d` would otherwise race its own
/// corpse for the session. Bounded so a wedged pump cannot hold the terminal
/// hostage; the process exit closes the socket either way.
pub fn awaitDetach(t: *Tile, shared: *Shared) void {
// The pump is the only thread that sets `detach_ack`, so one that has
// already returned can only run the wait out: 400ms of a wall that has
// said goodbye.
if (!t.alive.load(.acquire)) return;
const deadline = std.time.milliTimestamp() + 400;
while (!t.detach_ack.load(.acquire)) {
const left = deadline - std.time.milliTimestamp();
if (left <= 0) return;
var fds = [_]std.posix.pollfd{
.{ .fd = shared.kb_r, .events = std.posix.POLL.IN, .revents = 0 },
};
_ = std.posix.poll(&fds, @intCast(@min(left, 100))) catch return;
drainBell(shared.kb_r);
}
}
/// The session on this host that mux itself is running inside, if any.
pub fn selfSession(target: client.Target, env_sock: ?[]const u8, env_session: ?[]const u8) ?[]const u8 {
const es = env_session orelse return null;
if (!showsSelf(target, es, env_sock, env_session)) return null;
return proto.resolveName(es);
}
/// For a caller already holding `paint_mu`.
fn takeNoticeLocked(shared: *Shared, out: []u8) []const u8 {
const said = wall_picker.peekNoticeLocked(shared, out);
shared.notice_len = 0;
return said;
}
/// The DIAL is on the main thread, before any wall: ssh can want the tty.
/// The pump ADOPTS a link that is already up.
pub fn runAttach(
alloc: std.mem.Allocator,
target: client.Target,
session_name: []const u8,
key: ?[]const u8,
idle_ms: u32,
agent: bool,
own_version: []const u8,
) !u8 {
// Anything typed while the first handshake is in flight belongs to the
// shell, so it is held rather than dropped — and handed to the tile's
// mailbox once there is a tile to hand it to.
var carry: std.ArrayList(u8) = .empty;
defer carry.deinit(alloc);
// The one dial with a person waiting on it, so the one dial that keeps
// ssh's last line: the failure below is said in ssh's words when ssh
// had any, and in mux's only when it did not.
var dial: handoff.Dial = .{};
var transport = client.Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO, &dial) catch |err| {
var buf: [client.open_err_len]u8 = undefined;
const f = client.openFailure(&buf, target, err, dial.reason.slice());
std.debug.print("{s}", .{f.msg});
return f.exit;
};
// `mux HOST` is the whole wall zoomed on HOST, not a wall of one: the
// dialled host is tile 0 and the sidecar's anchor, every other listed
// host after it. Unless there is no terminal — then the specs stop at the
// entry host, so a piped `mux` writes what the plain client wrote.
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const arena = arena_state.allocator();
const spelling = try wall_host.hostSpelling(arena, target);
// The ask is SPENT: the open above is the dial the user waited for,
// and everything downstream of this spec — the poller, the grading of
// this host's panes, the entry tile's reconnects — is the wall acting
// on its own.
var spec_target = target;
if (spec_target == .hand) spec_target.hand.asked = false;
var specs: std.ArrayList(HostSpec) = .empty;
try specs.append(arena, .{
.spelling = spelling,
.target = spec_target,
// A failure here leaves the poll on the recipe the ATTACH used: it
// can prompt, which is worse, but a wall that refuses to open over
// one allocation is worse still. Unasked either way — a poll that
// could start a daemon is not a lesser evil, it is the bug.
.poll_target = client.pollTargetFor(arena, spec_target) catch spec_target,
});
if (hosts.statePath(arena) catch null) |path| {
// stderr, not a notice: this runs before `run` takes the screen.
if (wall_host.recordHost(arena, target, spelling, path)) |err|
std.debug.print("mux: hosts file not updated ({s}): {s}\n", .{ path, hosts.reason(err) });
if (!headless(std.posix.STDOUT_FILENO))
wall_host.otherHosts(arena, &specs, spelling, path, key, idle_ms);
}
// The narration was owed to a person at a BARE prompt, and the wall is
// about to take the screen: past here a late line from ssh would land on
// the alternate screen. A piped `mux` goes on relaying to its script.
if (!headless(std.posix.STDOUT_FILENO)) transport.narrate = false;
return run(alloc, specs.items, .{
.focus0 = true,
.pre = transport,
.carry = carry.items,
.entry_host = 0,
.entry_session = proto.resolveName(session_name),
.agent = agent,
.own_version = own_version,
.key = key,
.idle_ms = idle_ms,
// A scripted `mux` has pipes on both ends and is still a wall of
// one tile whose rect is the whole terminal. Only a wall of many
// needs a terminal.
.needs_tty = false,
}) catch |err| {
transport.close();
return err;
};
}
/// How a wall is ENTERED. One program, two doors: `mux` opens on the wall
/// itself, `mux TARGET` opens focused on the tile it just attached to.
/// Everything after the first paint is the same machinery.
const Entry = struct {
/// Start focused on tile 0 rather than on the wall. `mux [TARGET]`
/// opens here.
focus0: bool = false,
/// Tile 0's link, already open. See `Tile.pre`.
pre: ?client.Transport = null,
/// Keystrokes typed while that first handshake was in flight, owed to
/// the shell as soon as there is an attach to send them after. They go
/// through the mailbox like everything else a user types, so they reach
/// the session by the same path — prediction, mouse split and all.
carry: []const u8 = "",
/// Which host on the wall tile 0 belongs to. Null (with no `pre`) is
/// the plain wall: every tile arrives from a host's own list.
entry_host: ?usize = null,
/// The session tile 0 opens on that host; empty is the default one.
entry_session: []const u8 = "",
/// Whether tile 0's attaches offer this client's ssh-agent (`mux -A`).
/// Per tile, not per wall: a tile the user never named must not hand a
/// stranger's host the keys.
agent: bool = false,
/// This client's own version, handed in as data because only the cli
/// modules import `build_options`. `Shared.own_version` is set from it.
own_version: []const u8 = "",
/// The key and idle timeout a `quic://` host is resolved with — the
/// ones this invocation was given.
key: ?[]const u8 = null,
idle_ms: u32 = client.quic_idle_ms_default,
/// Whether this wall insists on a terminal. A wall of many does — there
/// is nothing to cut stripes from without one. A scripted `mux TARGET` is
/// a wall of one whose rect is the whole terminal, so it does not.
needs_tty: bool = true,
};
/// Run the wall until it is left. Never returns on the success path — see
/// the exit at the bottom.
pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) !u8 {
const stdin_fd = std.posix.STDIN_FILENO;
const stdout_fd = std.posix.STDOUT_FILENO;
const measured = interact.ttySize(stdout_fd);
if (measured == null and entry.needs_tty) {
std.debug.print("mux: wall needs a terminal\n", .{});
return 2;
}
// The plain client's fallback, byte for byte: a piped `mux` painted an
// 80x24 grid and this one has to paint the same one.
const size = measured orelse proto.Size{ .cols = 80, .rows = 24 };
// Whether there is a TERMINAL and whether it could be MEASURED are two
// questions, answered apart: a 1x1 pty is a real terminal reporting a
// size nothing can paint at.
const is_tty = std.posix.isatty(stdin_fd);
var shared = Shared{
.out_fd = stdout_fd,
.size = size,
.is_tty = is_tty,
.own_version = entry.own_version,
};
shared.tree = layout.Tree.init(alloc);
shared.flat_alloc = alloc;
// Resolved before the seed reads it and before any tile is seated: a
// pipe has no stripes, so it has no layout worth remembering — and a
// tree it saved would be a tree the next TERMINAL seeds over the
// aspect rule. A state dir that cannot be named leaves this null, and
// the wall then runs exactly as a piped one does.
if (is_tty) shared.layout_path = hosts.layoutPath(alloc) catch null;
defer if (shared.layout_path) |p| alloc.free(p);
const env_sock = std.posix.getenv(proto.sock_env);
const env_session = std.posix.getenv(proto.session_env);
// The host table before the first flatten, because the seed matches
// saved leaves against these spellings. Allocated at CAPACITY: a
// poller thread holds its `*Host` for the wall's whole life, so the
// array may never move when the picker's `a` adds a host to it.
const host_table = try alloc.alloc(Host, max_tiles);
var hosts_live: usize = @min(host_specs.len, max_tiles);
for (host_table[0..hosts_live], host_specs[0..hosts_live]) |*h, spec| {
h.* = .{
.spec = spec,
.shared = &shared,
.self_name = selfSession(spec.target, env_sock, env_session),
};
}
const has_entry = entry.pre != null and entry.entry_host != null;
var entry_name: []const u8 = "";
var entry_label: ?[]const u8 = null;
if (has_entry) {
// Duped, though argv outlives the process: a tile that ends frees
// these two when its digit is taken back.
entry_name = try alloc.dupe(u8, proto.resolveName(entry.entry_session));
entry_label = try tileLabel(alloc, host_specs[entry.entry_host.?].target, entry_name);
}
// A `--via CMD` wall records nothing, and that includes its own pane.
// `hosts.zig` refuses to write a `--via` line — an arbitrary command is
// not an address — so no hosts table can ever hold a row for one, while
// `tileLabel` still spells the tile `--via CMD#NAME` for its bar. Left
// alone, the start-up `persist` writes that spelling as a leaf and the
// NEXT start's `seedLayout` finds no host for it and refuses the WHOLE
// file: one throwaway `--via` run costs the user every pane they
// authored. Same rule as the trimmed seed below — a run that cannot
// record what it is showing stops recording, before it reads the file
// as well as before it writes one, and says so where the wall says
// everything else.
if (has_entry and host_specs[entry.entry_host.?].target == .via) {
stopSaving(&shared, alloc, "[layout not saved: a --via wall is not recorded]");
}
// The wall starts with the cut the layout file remembers: its leaves are
// pending panes the polls bind in place or the settle collapses, so
// the first paint is the saved shape, not a placeholder to re-cut.
var refused_line: [96]u8 = undefined;
var refused_notice: [96]u8 = undefined;
// The plan is MOVED out of this below and owned by `seed_plan` from
// there on; nothing reads the verdict again.
const seed_res: wall_layout.SeedResult = if (measured != null)
wall_layout.seedSidecar(alloc, host_table[0..hosts_live], &shared, entry_label, &refused_line)
else
.none;
var seed_plan: ?wall_layout.SeedPlan = switch (seed_res) {
.plan => |pl| pl,
else => null,
};
const seeded = seed_plan != null;
switch (seed_res) {
// A file this run could not read is a file this run must not write.
// The wall boots on the default cut, and the entry tile's start-up
// `persist` would then replace the user's whole authored wall with
// the one leaf it has — the panes gone before a key was pressed,
// and the printed line pointing at a file that no longer holds the
// mistake it names. Nothing is written for the rest of the run; the
// next start reads the same file and refuses it the same way.
.refused => |line| stopSaving(&shared, alloc, refusalNotice(&refused_notice, line)),
// The file is not wrong — this wall simply cannot seat any of it,
// because every leaf is the session this `mux` is running inside.
// Same rule: a run that shows none of the file does not rewrite it.
.self_only => stopSaving(&shared, alloc, "[layout not saved: it names only this shell's own session]"),
else => {},
}
// A seed that could not seat everything the file named is not this
// run's to write back either. `persist` serializes the tree it HAS, so
// the first save would drop the leaves this wall left out — the user's
// other panes gone before they touched a key, and no undo. The whole
// run stops saving instead, and says so once; the next start on a
// terminal that fits them saves again.
if (seed_plan) |pl| {
if (pl.dropped > 0) {
var why: [96]u8 = undefined;
const fit = pl.dropped - pl.dropped_self;
stopSaving(&shared, alloc, if (fit > 0)
std.fmt.bufPrint(&why, "[layout not saved: terminal too small for {d} of its panes]", .{fit}) catch
"[layout not saved: terminal too small for it]"
else
"[layout not saved: it names this shell's own session]");
}
}
if (seed_plan == null and has_entry) shared.tree.addFirst(0) catch return 2;
const init_flat = shared.tree.flatten(
alloc,
size.rows,
size.cols,
wall_layout.floorsOf(&shared),
null,
) catch {
std.debug.print("mux: terminal too small\n", .{});
return 2;
};
shared.last_flat = init_flat;
// A daemon that dies mid-write must surface as a write error on that
// tile's thread, not a process-fatal SIGPIPE.
proxy.ignoreSigpipe();
// Raw mode and the alternate screen only when there is a terminal to
// put back afterwards. On a pipe both are skipped and there is nothing
// to restore, which is what makes a scripted attach's capture identical
// to the one the plain client produced.
const orig: ?std.posix.termios = if (is_tty) try std.posix.tcgetattr(stdin_fd) else null;
if (orig) |o| {
var raw = o;
raw.lflag.ICANON = false;
raw.lflag.ECHO = false;
// ISIG and IXON off because those keys belong to the SESSION: Ctrl-C
// must reach the remote shell as a byte. It is also why `Ctrl-\` is
// the only way out — nothing else here raises a signal.
raw.lflag.ISIG = false;
raw.iflag.IXON = false;
raw.iflag.ICRNL = false;
try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
// Alternate screen, cursor hidden, autowrap off, title pushed — from
// interact's own constant, because the exit must undo what a focused
// TILE added on top and only that module knows the whole list.
proto.writeAllFd(stdout_fd, interact.wall_setup) catch {};
// Armed by the wall and not by any Core: the wall owns this
// terminal's raw mode. The KEYBOARD thread reads the process-wide
// winch flag and relayouts; no pump consumes it.
interact.watchWinch();
}
// From here to the key loop every `try` unwinds past the restore below,
// leaving the user on an alternate screen in raw mode with no prompt.
// Only that window: every ordinary exit is the teardown at the bottom.
errdefer if (orig) |o| {
proto.writeAllFd(stdout_fd, interact.wall_teardown) catch {};
std.posix.tcsetattr(stdin_fd, .FLUSH, o) catch {};
};
// The pumps' way of waking a keyboard asleep in read(2). Created before
// any pump exists, because a pump that ends immediately (a refused
// attach) rings it before the keyboard has ever polled.
const kb = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
shared.kb_r = kb[0];
shared.kb_w = kb[1];
shared.sel = 0;
// ssh's prompts get somewhere to go, on a TERMINAL only: without one
// there is no popup, so ssh keeps its prompts and fails them visibly.
// `sockpath.runtimeDir` is the one place that names the directory, so
// the popup lands beside the daemon socket on whatever OS this is —
// and what keeps this from being a password prompt any other local
// user could answer is that directory's privacy, which each OS buys
// differently. On Linux it is $XDG_RUNTIME_DIR with no fallback at
// all, so there is no /tmp path to share. On Darwin the fallback is
// /tmp/mux-<uid>, inside a world-writable directory, and what carries
// the guarantee there is `sockpath.runtimeDir` refusing that name
// unless it is a directory this uid owns at mode 0700.
var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined;
if (is_tty) {
if (sockpath.runtimeDir()) |rt| {
if (askpass.Listener.start(alloc, rt, .{
.ctx = &shared,
.wake = ringKeyboardCtx,
})) |l| {
shared.prompts = l;
// The image that is RUNNING, resolved by `spawn.selfExe`
// and never a PATH walk: ssh execs this as its helper, and
// a helper found by name is whichever mux the environment
// happens to have.
shared.prompt_exe = spawn.selfExe(&ask_exe_buf);
} else |_| {}
}
}
// The ERROR returns between here and the keys loop, and those only:
// this function ends in `std.posix.exit`, which reaches no defer at
// all. The normal way out is `retire` at the tail, and the two are not
// interchangeable — see there.
defer if (shared.prompts) |l| l.stop();
// Allocated at CAPACITY, not at length: the pump threads are detached
// and hold `*Tile` for the wall's whole life, so the array may never
// move. `live` is how much of it is real.
const tiles = try alloc.alloc(Tile, max_tiles);
var live: usize = 0;
const present = try alloc.alloc(bool, max_tiles);
@memset(present, false);
// One value for the six the wall used to pass around. `hosts` is
// re-sliced wherever `hosts_live` grows — the picker's `a` is the only
// thing that does.
var w: Wall = .{ .alloc = alloc, .tiles = tiles, .present = present, .live = &live, .shared = &shared };
w.hosts = host_table[0..hosts_live];
if (has_entry) {
const hi = entry.entry_host.?;
const target = host_specs[hi].target;
const rect = init_flat.rectOf(0) orelse return 2;
try initTile(&tiles[0], .{
.target = target,
.label = entry_label.?,
.session = entry_name,
.agent = entry.agent,
}, rect, &shared, 0, .fresh);
tiles[0].host = hi;
tiles[0].pre = entry.pre;
// `mux TARGET` is attach-or-create: the attach carries the rect,
// which is what the daemon reads create-vs-join off.
tiles[0].creates = true;
// The entry tile keeps the plain client's cold-loss rule; see
// `Tile.retry_cold`.
tiles[0].retry_cold = false;
// The handshake's keystrokes, before the pump that will send them
// exists. Whatever will not fit is dropped whole, `sendKeys`'
// reason: a spliced command is worse than a lost one.
const n = @min(entry.carry.len, tiles[0].in.len);
@memcpy(tiles[0].in[0..n], entry.carry[0..n]);
tiles[0].in_len = n;
present[0] = true;
w.live.* = 1;
// Its pump claims the terminal on its first pass. Set before
// `spawnPump` so the claim is the first thing the pump does after
// the dial, and the attach already carries the terminal's size.
tiles[0].claim_pending.store(true, .release);
}
if (seed_plan) |*pl| {
for (pl.panes, 0..) |mp, i| {
const pane = mp orelse continue;
const rect = init_flat.rectOf(@intCast(i)) orelse layout.Rect{ .top = 0, .left = 0, .rows = 0, .cols = 0 };
try seedTile(&tiles[i], .{
.target = host_table[pane.host].spec.target,
.label = pane.label,
.session = pane.session,
}, rect, &shared, i, pane.host);
present[i] = true;
}
live = pl.panes.len;
// The saved focus, unless the user is already typing into the
// entry tile - a focus record must not move them off it.
if (!has_entry) shared.sel = pl.focus orelse (wall_layout.firstPresent(present[0..live]) orelse 0);
// The names moved into the tiles above; only the array goes.
alloc.free(pl.panes);
seed_plan = null;
}
for (tiles[0..live]) |*t| spawnPump(t);
// A wall with no tile yet paints its one line rather than nothing (a
// blank terminal with no cursor reads as hung), and a seeded wall
// paints its remembered cut: rails and waiting bars have no pump.
if (w.live.* == 0 or seeded) wall_layout.relayout(w, shared.sel);
// The entry tile is a pane the user just added to the wall, whichever
// road seated it — `addFirst(0)` on a wall with no saved tree, or the
// seed's own insertion beside the leaves the file named. Written here
// and not at the exit, so `mux HOST` in one terminal is on the wall the
// next terminal starts. A wall with no entry tile added nothing, and a
// save there would write over a file this run may have refused to read.
if (has_entry) wall_layout.persist(w);
// One poller per host, all of them at once and none of them on this
// thread: the user asked for one session and must not be held on
// another host's ssh to see it.
var over_buf: [64]u8 = undefined;
if (wall_host.hostsOverCapacity(&over_buf, host_specs.len)) |said| setNotice(&shared, said);
for (host_table[0..hosts_live]) |*h| {
// Not without a terminal: a poller here would turn every scripted
// `mux --sock S` into a wall of whatever that daemon is running.
// Nothing to wait for when nothing was started.
if (measured == null) {
h.poller_done.store(true, .release);
continue;
}
const th = std.Thread.spawn(.{}, wall_host.pollHost, .{h}) catch {
h.poller_done.store(true, .release);
continue;
};
th.detach();
}
// Where the picker's `a` writes the host it adds. Resolved once, up
// here: a popup is not the place to find out there is no state dir, and
// a null path leaves the host on this wall and out of the file rather
// than refusing it.
const hosts_path = hosts.statePath(alloc) catch null;
defer if (hosts_path) |p| alloc.free(p);
// The wall is always "in": every non-chord byte goes to the focused tile.
// The keyboard holds back only the `Ctrl-\` prefix and hit-tests presses
// for focus; the report bytes still go through, so the drag's Core sees them.
var input: WallInput = .{};
// Which HOST the picker's selection rests on, kept across opens so a
// popup reopened by reflex is where it was left. Keyboard-thread
// state, like the focus.
var picker_sel: usize = 0;
// Which SESSION row the picker's second level rests on, as an index into
// `picker_sel`'s host list. Reset on every level change rather than kept
// like `picker_sel`: a row number means nothing on another host's list,
// and the daemons answer while the box is open.
var picker_row: usize = 0;
// The prompt on screen, kept because the box is repainted on every
// keystroke and the question does not come round again.
var ask_prompt: askpass.Prompt = .{};
// Whether the picker on screen is one the WALL opened, not the user:
// an empty wall opens it once, and an Esc there has to be able to
// leave the empty line showing rather than be re-opened over.
var picker_auto: PickerAuto = .{};
// Whether the popup was already on the screen when this key arrived,
// so an OPEN can be told from a keystroke inside one.
var picker_shown = false;
// `feed` hands a candidate held across the previous read back ahead of
// this chunk, so its room is a whole chunk plus that hold.
var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
var mouse_filter: interact.MouseFilter = .{};
// A vanish sentence deferred to the normal screen: printed after raw
// mode is restored, alongside the exit message below.
var vanish_msg: ?[]const u8 = null;
// How this run ends: the code, and the one sentence that goes with it
// on the normal screen once the terminal is back.
var exit_code: u8 = 0;
var exit_msg: ?[]const u8 = null;
// The wall SETTLES once: every opening host has answered, or 2s - the
// poll's own dial budget. It gates the picker's auto-open and nothing
// else; only a host's own list may take a pane (`dressSilent`).
const settle_due: i64 = std.time.milliTimestamp() + 2000;
var settled = false;
// Only the hosts the wall OPENED with are waited for. One added in the
// picker arrives long after this, on a wall the user is already
// looking at, and holding the settle for it would relay their panes
// under their hands.
const opening_hosts = hosts_live;
// The aspect heuristic is the fallback for a wall with no saved tree,
// and it decides once — the first time the wall holds two tiles.
var oriented = false;
// Stdin gone is not the wall gone. A piped `mux` whose script has run
// out still has a session on the far end, exactly as the plain client
// did — it ends when that session does.
var stdin_open = true;
var b: [mailbox_max]u8 = undefined;
keys: while (true) {
var fds = [_]std.posix.pollfd{
.{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 },
.{ .fd = shared.kb_r, .events = std.posix.POLL.IN, .revents = 0 },
};
// A 100ms cap so the keyboard can check the process-wide winch
// flag without a pump having to — `winch` is one flag and the tile
// that would have answered it is no longer special.
const winch = interact.winchRaised();
// A finite timeout, not -1: the flag is a flag, and poll retries
// on EINTR — a SIGWINCH landing mid-poll would otherwise wait for
// the next keystroke to be noticed. 100ms is the pumps' cadence.
_ = std.posix.poll(&fds, if (winch) 0 else 100) catch break;
if (winch) {
const measured2 = interact.ttySize(stdout_fd) orelse continue;
if (measured2.cols != shared.size.cols or measured2.rows != shared.size.rows) {
shared.paint_mu.lock();
shared.size = measured2;
shared.paint_mu.unlock();
wall_layout.relayout(w, shared.sel);
}
}
if (fds[1].revents != 0) {
drainBell(shared.kb_r);
// Answers first, ends second: a chord that has just been
// answered may move the focus onto a tile whose end we are
// about to read, and acting on the end of a tile nobody is
// looking at any more is the wrong order to notice things in.
const z = shared.sel;
if (z < w.live.* and tiles[z].ans_ready.swap(false, .acq_rel)) {
var name: client.SessionName = undefined;
{
tiles[z].ans_mu.lock();
defer tiles[z].ans_mu.unlock();
name = tiles[z].ans;
}
// Tiles, not slots: a birth that took a vanished digit
// back leaves `live` — the high-water mark — exactly where
// it was, and a wall that read growth off it would put the
// new tile on a screen nothing re-cut.
const before = presentCount(present[0..live]);
switch (addSessionTile(w, z, name.slice(), tiles[z].pending_place)) {
.moved => |to| {
const grew = presentCount(present[0..live]) > before;
focusAnswer(w, grew, to);
// The chord's birth (`.new_session`, `.split_right`,
// `.split_below`) lands here, one round trip after
// the key: the answer is what makes the tile, so this
// is where the wall changed. An answer that only
// moved the focus onto a tile the wall already had
// changed nothing to write.
if (grew) wall_layout.persist(w);
},
.full => setNotice(&shared, "[no room on the wall for another tile]"),
.stay => {},
}
wall_host.pokeHost(w.hosts, &tiles[z]);
}
}
// Ends are read every pass, not only on the bell: two pumps dying
// together share one ring, and `drainBell` clears it, so a residual
// end would otherwise wait for a ring that may not come.
if (endedTile(tiles[0..live], present[0..live], &shared)) |ended| {
switch (endAction(tiles, present, w.live.*, ended, stdin_open, shared.is_tty)) {
.refocus => |to| {
if (!tiles[to].alive.load(.acquire))
setNotice(&shared, "[focus on a dead tile - no live neighbour]");
setFocus(tiles[0..live], &shared, to);
},
.vanish => |v| {
if (v.msg) |msg| {
// Banner on the surviving tile's rect; stderr deferred
// past the raw-mode restore. Both say the same words.
var nb: [128]u8 = undefined;
setNotice(&shared, noticeText(&nb, msg));
vanish_msg = msg;
}
// An unfocused tile ending must not move the user's
// focus: it goes back where it was, not where the dead
// tile pointed — `vanishTile` moves it only if the
// vanished tile had it.
if (v.back) |back| {
if (ended == shared.sel and !tiles[back].alive.load(.acquire))
setNotice(&shared, "[focus on a dead tile - no live neighbour]");
}
vanishTile(tiles[0..live], present[0..live], &shared, ended, v.back);
wall_layout.relayout(w, shared.sel);
// A pane whose session ended is a pane the wall no
// longer has: the file says so now, not at the next
// detach, so a crash cannot bring the dead one back.
wall_layout.persist(w);
},
.finish => |how| {
exit_code = how.code;
exit_msg = how.msg;
break :keys;
},
}
}
// A box whose ssh has GONE, before one that is arriving: the helper is
// SIGTERMed when the touch lands, so the prompt on screen is a question
// nobody waits on. Dismissing it by key would decline a live dial.
if (input.prefix.asking) {
if (shared.prompts) |l| {
if (!l.showing()) closeAsk(w, &shared, &input.prefix, picker_sel, picker_row);
}
}
// Every pass, not only on the bell: `endedTile`'s reason twice
// over — one drained ring can carry two pumps' news — and a prompt
// is an ssh already blocked on the answer.
if (takeAsk(&shared, &input.prefix, &ask_prompt))
wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix);
// Ends first, lists second: a session that exited is its pump's
// news and arrives at once, while a list is up to a poll behind.
// Reading the list first would vanish the tile the exit code is on.
const host_news = wall_host.applyReadyLists(w);
if (!settled) {
var all_reported = true;
for (host_table[0..opening_hosts]) |*h| {
if (!h.applied) {
all_reported = false;
break;
}
}
if (all_reported or std.time.milliTimestamp() >= settle_due)
settled = true;
}
if (!seeded and !oriented and presentCount(present[0..live]) > 1) {
oriented = true;
shared.paint_mu.lock();
shared.tree.setRootOrient(wall_layout.rootOrient(shared.size));
shared.paint_mu.unlock();
wall_layout.relayout(w, shared.sel);
}
// An empty wall IS the picker: there is nothing else on the screen
// to act from, and the last `x` is exactly when the user needs the
// list of machines. Once, so the Esc that closes it leaves the
// one-line empty-wall text standing.
var picker_opened = false;
// `settled` is the gate, not emptiness alone: it is the moment
// every opening host has answered (or 2s), and a wall still dialling
// is not known to be empty. A popup flashed over a wall that is
// about to have tiles is one the user never asked for.
switch (picker_auto.step(
shared.is_tty and settled,
presentCount(present[0..live]) == 0,
input.prefix.picking,
input.prefix.prompting,
)) {
.open => {
picker_opened = true;
picker_shown = true;
input.prefix.picking = true;
// The wall's own open is a HOST list: the level is the
// filter's, and nothing else here would put it back.
input.prefix.pick_level = .hosts;
picker_row = 0;
picker_sel = wall_picker.pickerNearest(w.hosts, picker_sel);
},
.close => closePicker(w, &input.prefix, &picker_shown, null),
.leave => {},
}
// On news, not on the tick: the rows follow the pollers while the
// popup is open, but a repaint every 100ms rewrites an unchanged
// screen. A zeroed stamp is a screen the box is no longer on —
// `relayout` cleared it, and nothing else would repaint the popup.
// The lists move under the box: a poll that ended a session while
// the popup was open leaves the selection past the last row, and
// the highlight would be on nothing.
if (input.prefix.pick_level == .sessions)
picker_row = @min(picker_row, wall_picker.sessionCount(w.hosts, picker_sel) -| 1);
var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
const repair = wall_picker.pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or
winch or fds[1].revents != 0 or shared.picker_frame.stamp == 0);
if (repair.due)
wall_picker.paintPicker(w, picker_sel, .{ .level = input.prefix.pick_level, .row = picker_row }, repair.line);
if (fds[0].revents == 0) continue;
const n = std.posix.read(stdin_fd, &b) catch break;
if (n == 0) {
stdin_open = false;
// The session outlives the script that was typing at it;
// `endAction` is what ends the run when that session does.
continue;
}
const z = shared.sel;
const cmd = input.prefix.feed(b[0..n]);
// Over EVERYTHING, the picker included: ssh is blocked on this
// answer, and the box that owns the keyboard is the one that
// arrived last. Nothing is forwarded — the filter keeps every byte
// while `asking`, which is what stops a password reaching a shell.
if (input.prefix.asking or wall_picker.isAskAction(cmd.action)) {
// A NOTICE takes no answer, whichever key ended it: ssh asked
// nothing — "Confirm user presence for key ..." — and a decline
// recorded here would park a tile whose dial is still going.
// The box just leaves; the helper is ssh's to kill.
const answerable = ask_prompt.kind != .notice;
switch (cmd.action) {
.ask_answer => |line| if (answerable) {
if (shared.prompts) |l| l.answer(line);
},
.ask_decline => if (answerable) {
if (shared.prompts) |l| l.decline();
},
else => {},
}
if (input.prefix.asking) {
wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix);
continue;
}
// ssh asks in sequence and the next helper is already in the
// backlog: reopening BEFORE the close saves a screen that flashes
// back to the wall between two halves of one login.
if (takeAsk(&shared, &input.prefix, &ask_prompt)) {
wall_picker.paintAsk(&shared, ask_prompt.slice(), &input.prefix);
continue;
}
closeAsk(w, &shared, &input.prefix, picker_sel, picker_row);
continue;
}
// The picker is a MODE: while it is open every key is the popup's,
// so neither the empty wall's arms below nor the tiles' ever see
// one — and the popup is the one thing that works on a wall with
// no tile at all, which is why it is answered ahead of both.
if (input.prefix.picking or wall_picker.isPickAction(cmd.action)) {
// Typed AT the session, ahead of the chord, in the same read.
if (cmd.forward.len > 0 and z < w.live.* and present[z]) keysToFocused(&tiles[z], cmd.forward);
var birth_at: ?usize = null;
// Opening on a focused tile pre-selects that tile's host: the
// machine the user is already on is the one they mean. Judged
// on the popup's own state, not on `.pick_open`, because one
// read can open the picker and its editor together.
if (!picker_shown) {
if (z < w.live.* and present[z]) {
if (tiles[z].host) |hi| picker_sel = hi;
}
picker_sel = wall_picker.pickerNearest(w.hosts, picker_sel);
picker_row = 0;
}
// The level the FILTER left, not the one the key arrived at: it
// flips `pick_level` before returning, so `.sessions` on a
// `.pick_enter` means the list just opened and `.hosts` means a
// session was chosen and the popup is closing.
const level = input.prefix.pick_level;
switch (cmd.action) {
.pick_move => |d| if (level == .sessions) {
picker_row = wall_picker.rowStep(picker_row, d, wall_picker.sessionCount(w.hosts, picker_sel));
} else {
picker_sel = wall_picker.pickerStep(w.hosts, picker_sel, d);
},
.pick_select => |row| if (level == .sessions) {
picker_row = @min(row - 1, wall_picker.sessionCount(w.hosts, picker_sel) -| 1);
} else {
if (wall_picker.pickerAt(w.hosts, row - 1)) |hi| picker_sel = hi;
},
.pick_enter => if (level == .sessions) {
// Enter at the host level: the session list just opened,
// and there is nothing to do but paint it from the top.
picker_row = 0;
// ...unless there is no host under the selection at all.
// An empty hosts file leaves `picker_sel` at 0 with no
// row there, and a level with nothing in it is a box the
// user has to Esc back out of for nothing.
if (picker_sel >= w.hosts.len or w.hosts[picker_sel].forgotten.load(.acquire)) {
input.prefix.pick_level = .hosts;
setNotice(&shared, "[no host to list sessions on - a adds one]");
}
} else {
// Enter at the session level: the popup closed on a
// choice, and that session becomes a pane.
birth_at = wall_picker.pickAdd(w, picker_sel, picker_row);
},
// Back at the hosts, so the row means nothing any more.
.pick_back => picker_row = 0,
.pick_end => wall_picker.pickEnd(w, picker_sel, picker_row, std.time.milliTimestamp()),
.pick_birth => birth_at = wall_picker.pickBirth(w, picker_sel),
.pick_forget => wall_picker.pickForget(w, picker_sel, hosts_path),
.add_tile => |spelling| {
// The editor adds a HOST, so its answer belongs on the
// host list — whichever level the `a` was typed at.
input.prefix.pick_level = .hosts;
picker_row = 0;
switch (wall_host.addHost(
alloc,
&shared,
host_table,
&hosts_live,
spelling,
entry.key,
entry.idle_ms,
hosts_path,
)) {
.added => |hi| {
// The only thing that grows `hosts_live`, so the
// only place the wall's own slice has to follow.
w.hosts = host_table[0..hosts_live];
const th = std.Thread.spawn(.{}, wall_host.pollHost, .{&host_table[hi]}) catch null;
if (th) |handle| handle.detach();
// The row the user just made is the row they meant.
picker_sel = hi;
picker_row = 0;
},
// The row it is already on is the row they meant too:
// an unchanged popup is the prompt saying nothing.
.listed => |hi| {
picker_sel = hi;
picker_row = 0;
},
.refused => {},
}
},
else => {},
}
picker_shown = input.prefix.picking;
if (input.prefix.picking) {
var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
const keyed = wall_picker.pickerRepaint(&line_buf, &input.prefix, true);
wall_picker.paintPicker(w, picker_sel, .{ .level = input.prefix.pick_level, .row = picker_row }, keyed.line);
} else {
// A close the USER asked for: the auto-open no longer owns
// this popup, so the next tile to arrive cannot force-close
// a box the user opened themselves.
picker_auto.taken();
closePicker(w, &input.prefix, &picker_shown, birth_at);
// `pickBirth` wrote the new pane; the close is what moved
// the focus onto it, and the focus is in the file too.
if (birth_at != null) wall_layout.persist(w);
// The focused pump may be holding a claim the popup refused;
// it re-arms and retries on its next pass, and this is what
// makes that pass happen now rather than within a poll.
closeNotice(tiles[0..live], present[0..live], &shared);
}
continue;
}
// An EMPTY wall has no `tiles[z]`, and every branch below reads one.
// `live` cannot answer it — a slot high-water never falls — so the
// `present` roll is what `relayout` counted. No sidecar on the way
// out: writing one would erase the layout the user last left.
if (presentCount(present[0..live]) == 0) {
switch (cmd.action) {
.detach => {
exit_code = 0;
exit_msg = "mux: left the wall";
break :keys;
},
.new_session, .split_right, .split_below => {
setNotice(&shared, "[no session to birth beside - Ctrl-\\ s picks a host]");
wall_layout.relayout(w, shared.sel);
},
else => {},
}
continue;
}
// A BARE Ctrl-\ with no session to command is the user giving up on
// the wait. Judged after the filter, so `\x1c` still holding out for
// its command key stays apart from `\x1c w` asking for the wall.
if (cmd.action == .none and input.prefix.pending) {
if (awaitingSession(&tiles[z])) |waiting| {
exit_code = 0;
exit_msg = switch (waiting) {
// Nothing has been detached from a session that
// never came up.
.connecting => "mux: aborted before attaching",
else => "mux: detached while reconnecting (session still running; run mux to reattach)",
};
break :keys;
}
}
// What preceded the chord was typed AT the focused session, and
// it is queued BEFORE the focus moves — so `Ctrl-\ n` cannot
// deliver the tail of a word to the tile it is jumping to.
if (cmd.forward.len > 0) {
// A press in another tile's rect moves the focus there; a press in
// the focused tile is not intercepted at all. `Event.at` is the
// byte offset of each report, because keys before a click belong to
// the OLD focus and keys after it to the new.
const report = mouse_filter.feed(cmd.forward, &mouse_out);
var seg_start: usize = 0;
for (report.events) |ev| {
if (ev.kind != .press) continue;
if (rectHit(tiles[0..live], present[0..live], &shared, ev.row, ev.col)) |hit| {
if (hit != shared.sel) {
if (ev.at > seg_start)
keysToFocused(&tiles[shared.sel], cmd.forward[seg_start..ev.at]);
seg_start = ev.at;
setFocus(tiles[0..live], &shared, hit);
}
}
}
keysToFocused(&tiles[shared.sel], cmd.forward[seg_start..]);
}
switch (cmd.action) {
.none => {},
// The picker's own keys: the popup answers them, never a tile.
.pick_open,
.pick_move,
.pick_select,
.pick_birth,
.pick_forget,
.pick_add_open,
.pick_close,
.pick_enter,
.pick_back,
.pick_end,
=> {},
// ssh's own keys, answered above this switch for the reason
// the picker's are: the box is the only thing on the screen
// that a keystroke can be for.
.ask_answer, .ask_decline => {},
.detach => {
// The slot goes back to the daemon before this process does.
// The pump writes the frame; this waits briefly for the ack, so
// a `mux` typed straight after finds the session free.
tiles[z].detach_req.store(true, .release);
ring(&tiles[z]);
awaitDetach(&tiles[z], &shared);
// No save here, and none on `.finish` either. Every change
// to the pane set or the tree has already written the file,
// so an exit has nothing left to record — and two terminals
// on one device made the exit-time save actively wrong: the
// passive one, showing a tree from before the other's edits,
// wrote its stale copy back on the way out and undid them.
// The focus is the only thing that goes unsaved by this, and
// a focus is not authored.
exit_code = 0;
exit_msg = "mux: detached (session still running; run mux to reattach)";
break :keys;
},
.wall => {
// Nothing left to fold in: every tile the wall will ever
// have is already here or on its way from a host's list.
// `Ctrl-\ w` is a re-cut of the stripes, and an unzoom.
wall_layout.relayout(w, shared.sel);
},
.new_session, .split_right, .split_below => {
// Ask the focused tile's daemon for a new session. The
// answer arrives on the pump's transport and is posted
// back to the keyboard (`postAnswer`), which moves the
// focus or grows the wall.
tiles[z].pending_place = switch (cmd.action) {
.split_right => .right_of,
.split_below => .below,
else => .beside_focus,
};
tiles[z].ask.store(@intFromEnum(client.SwitchIntent.new), .release);
if (ringLive(&tiles[z])) {
wall_host.pokeHost(w.hosts, &tiles[z]);
} else {
setNotice(&shared, no_live_here);
showRefusal(tiles[0..live], &shared, z);
}
},
.next_session, .prev_session => {
// Client-local, and no frame at all: the wall already knows
// every session every host has, so asking one daemon for a
// ring would walk its slice and skip the rest of the wall.
if (walkTiles(present[0..live], z, cmd.action == .next_session)) |to| {
focusAnswer(w, false, to);
}
},
.focus_dir => |d| {
const flat = shared.base_flat orelse shared.last_flat;
if (flat) |f| {
if (layout.neighbor(f, @intCast(z), wall_layout.dirOf(d))) |nb| {
if (nb < w.live.* and present[nb] and nb != z) {
if (shared.fullscreen)
focusAnswer(w, false, nb)
else
setFocus(tiles[0..live], &shared, nb);
}
}
}
},
.fullscreen => {
shared.fullscreen = !shared.fullscreen;
wall_layout.relayout(w, shared.sel);
},
.resize => |d| {
// Only a resize that MOVED a boundary: a key the tree
// refused left the same tree behind, and rewriting it
// would cost a file write per held-down arrow.
if (wall_layout.doResize(w, z, d)) wall_layout.persist(w);
},
.focus => |idx| {
if (idx > 0 and idx <= w.live.* and present[idx - 1] and idx - 1 != z) {
if (shared.fullscreen)
focusAnswer(w, false, idx - 1)
else
setFocus(tiles[0..live], &shared, idx - 1);
}
},
.end_focused => if (z < w.live.* and present[z]) {
// Asked through THIS tile's own pump, not a side connection:
// the pump is already attached to the session, so the daemon
// excludes it from the "others hold it" count and the answer
// comes back on the link the tile is already reading. The
// picker dials its own connection because the row it stands
// on may have no pane here; a focused tile always does.
//
// Nothing is vanished on the keypress. The pane leaves the
// way an ended session's pane always leaves — the shell's
// `exit_status`, then the poll that no longer names it — so
// a refused end costs the wall nothing.
tiles[z].ask.store(
@intFromEnum(intentForEnd(&tiles[z], std.time.milliTimestamp())),
.release,
);
if (ringLive(&tiles[z])) {
wall_host.pokeHost(w.hosts, &tiles[z]);
} else {
// No pump, so no link to ask on. A `gone` pane's session
// is already off its daemon and an unreachable one's
// cannot be reached to be ended.
setNotice(&shared, no_live_here);
showRefusal(tiles[0..live], &shared, z);
}
},
.remove_pane => if (z < w.live.* and present[z]) {
removePane(w, z);
// A piped wall of one has nowhere to leave the tile off:
// scripts read the code, and the bare-chord abort says the
// same words for the same act.
if (!shared.is_tty and presentCount(present[0..live]) == 0) {
exit_code = 0;
exit_msg = "mux: aborted before attaching";
break :keys;
}
},
// The spelling editor is the picker's, so its Enter is
// answered up there with the rest of the popup's keys.
.add_tile => {},
}
}
shared.running.store(false, .release);
// The socket leaves the filesystem here rather than in the defer above,
// which this function's `exit` never reaches. `retire` and not `stop`:
// a detached pump may be inside `declined` on it, and a free in the
// window before the exit is a use-after-free.
if (shared.prompts) |l| l.retire();
// Taken and never released: no tile paints across the restore. The
// pump threads are detached and die with the process; joining them
// could wait on a blocked readFrame forever.
shared.paint_mu.lock();
if (orig) |o| {
// Every mode this terminal can be carrying, off — the wall's own
// and any focused tile's session set through it. A wall left
// through a detach chord or a session that ended must not leave a
// terminal reporting clicks into the user's shell.
proto.writeAllFd(stdout_fd, interact.wall_teardown) catch {};
std.posix.tcsetattr(stdin_fd, .FLUSH, o) catch {};
}
if (vanish_msg) |m| std.debug.print("{s}\n", .{m});
// The prediction stats, on the normal screen and before the exit
// sentence — exactly where and when `Core.deinit` used to put them for
// a plain client. Only for an ATTACH: a wall the user opened on no
// session never printed a stats line and gains no reason to start.
if (entry.focus0) interact.dumpPredictStats(shared.stats);
if (exit_msg) |m| std.debug.print("{s}\n", .{m});
// exit(), not return: returning would run the caller's frees and leak
// checks while detached pump threads still hold pointers into `tiles`
// and their own live transports — the window between here and process
// death must not contain a free.
std.posix.exit(exit_code);
}
// Reaching a file is what registers its tests; build.zig gates
// the list.
test {
_ = @import("interact.zig");
_ = @import("paint.zig");
_ = @import("predict.zig");
_ = @import("wall_test_harness.zig");
_ = @import("wall_test_host.zig");
_ = @import("wall_test_layout.zig");
_ = @import("wall_test_picker.zig");
_ = @import("wall_test_pump.zig");
_ = @import("wall_test_wall.zig");
}