a73x

89cc9067

refactor: the host picker's popup is wall_picker.zig

a73x   2026-08-28 20:49

Commit message
refactor: the host picker's popup is wall_picker.zig

`Ctrl-\ s` is a mode with its own rows, its own keys and its own frame;
none of it is the wall's tiles. Moved whole, bodies unchanged.

docscheck.budget
Old New
@@ -49,3 +49,4 @@ server_test_upgrade.zig 0
49 server_agent.zig 0 49 server_agent.zig 0
50 server_sessions.zig 0 50 server_sessions.zig 0
51 wall_host.zig 0 51 wall_host.zig 0
52 wall_picker.zig 0
src/tui/wall_picker.zig
Old New
@@ -0,0 +1,474 @@
1 //! `Ctrl-\ s`: the host picker's popup. A MODE of the wall's prefix
2 //! filter, so every byte typed here is the popup's and none reaches a
3 //! session. Rows are the hosts file's daemons in file order with the
4 //! poller's last answer beside each; Enter births, `x` forgets, `a` is the
5 //! spelling editor. Paints under `paint_mu` while `Shared.picker_open`
6 //! holds every tile off the terminal.
7 const std = @import("std");
8 const proto = @import("protocol");
9 const client = @import("client");
10 const hosts = @import("hosts");
11 const interact = @import("interact");
12 const wall_host = @import("wall_host.zig");
13 const wv = @import("wallview.zig");
14 const Host = wall_host.Host;
15 const Shared = wv.Shared;
16 const Tile = wv.Tile;
17
18 /// The widest row the picker draws. A spelling past it is cut, never
19 /// wrapped: a host list that reflows is one a digit cannot address.
20 pub const picker_row_max: usize = 128;
21
22 /// Under either, the popup is one row: a box that does not fit is worse
23 /// than a header saying which one it is.
24 const picker_min_cols: u16 = 24;
25 const picker_min_rows: u16 = 4;
26
27 /// One whole popup, written in one go: every row plus its cursor address
28 /// and its two SGRs, at the table's cap of rows.
29 const picker_frame_max: usize = (wv.max_tiles + 2) * (picker_row_max + 32) + 8;
30
31 /// The picker's rendered rows. Rendered once per paint and read twice —
32 /// by the writer and by the tests — so the box on the screen is the box
33 /// the claims are made about.
34 pub const PickerBody = struct {
35 text: [wv.max_tiles][picker_row_max]u8 = undefined,
36 lens: [wv.max_tiles]usize = [_]usize{0} ** wv.max_tiles,
37 /// Which host each row is, so a digit answers in host indices.
38 host: [wv.max_tiles]usize = [_]usize{0} ** wv.max_tiles,
39 n: usize = 0,
40
41 pub fn row(self: *const PickerBody, i: usize) []const u8 {
42 return self.text[i][0..self.lens[i]];
43 }
44 };
45
46 /// What the state column says: the POLLER's last answer, never the wall's
47 /// tiles. A host with no tiles is exactly the one the user is about to
48 /// birth on, and it has to say whether the daemon is answering at all.
49 pub fn hostState(buf: []u8, h: *Host) []const u8 {
50 // `applied` is set by the first list to reach the wall, well or badly,
51 // so a host that has never reported is `connecting` — not `no
52 // sessions`, which would send the user to birth on a dead machine.
53 if (!h.applied) return "connecting";
54 if (!h.reachable.load(.acquire)) return "unreachable";
55 var n: usize = 0;
56 h.list_mu.lock();
57 var it = std.mem.splitScalar(u8, h.list[0..h.list_len], '\n');
58 while (it.next()) |name| {
59 // The same filter `planHostDiff` births through, so the count a row
60 // advertises is the number of tiles that host can actually put on
61 // the wall.
62 if (proto.validSessionName(name)) n += 1;
63 }
64 h.list_mu.unlock();
65 if (n == 0) return "no sessions";
66 if (n == 1) return "1 session";
67 return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions";
68 }
69
70 /// One row: ` N> SPELLING STATE `, padded to `cols` so the box has an
71 /// edge. The spelling is cut from the LEFT, because the head of a socket
72 /// path is what every daemon on one machine has in common.
73 pub fn pickerRow(out: []u8, n: usize, wide: bool, spelling: []const u8, state: []const u8, selected: bool, cols: u16) []const u8 {
74 // The same two-wide marker a label bar wears, so the eye reads the
75 // popup and the wall the same way and rows do not shift as it moves.
76 // `wide` is the number's half of that: an unpadded 10 puts its spelling
77 // a column right of row 9's.
78 const marker: []const u8 = if (selected) "> " else " ";
79 var head_buf: [16]u8 = undefined;
80 const head = (if (wide)
81 std.fmt.bufPrint(&head_buf, " {d: >2}{s}", .{ n, marker })
82 else
83 std.fmt.bufPrint(&head_buf, " {d}{s}", .{ n, marker })) catch return out[0..0];
84 const room = @min(@as(usize, cols), out.len);
85 const field = room -| head.len -| state.len -| 2;
86 const cut = if (spelling.len > field) spelling[spelling.len - field ..] else spelling;
87 var w: usize = 0;
88 for ([_][]const u8{ head, cut }) |part| {
89 const take = @min(part.len, room - w);
90 @memcpy(out[w..][0..take], part[0..take]);
91 w += take;
92 }
93 while (w < room -| state.len -| 1 and w < room) : (w += 1) out[w] = ' ';
94 for ([_][]const u8{ state, " " }) |part| {
95 const take = @min(part.len, room - w);
96 @memcpy(out[w..][0..take], part[0..take]);
97 w += take;
98 }
99 return out[0..w];
100 }
101
102 /// Whether the popup on screen is one the WALL opened rather than the user,
103 /// and what the next pass through the keyboard loop owes it.
104 pub const PickerAuto = struct {
105 /// The popup currently on screen is the WALL's, not the user's.
106 on: bool = false,
107 /// This spell of emptiness has already had its one auto-open. Distinct
108 /// from `on`, and that is the whole of it: the Esc that closes the
109 /// wall's popup leaves the wall STILL EMPTY, and a single flag would
110 /// reopen it over the one-line text the Esc asked for.
111 spent: bool = false,
112
113 pub const Step = enum { open, close, leave };
114
115 /// What an empty (or newly un-empty) wall owes the popup.
116 pub fn step(self: *PickerAuto, is_tty: bool, empty: bool, picking: bool, prompting: bool) Step {
117 if (empty) {
118 if (is_tty and !picking and !self.spent) {
119 self.spent = true;
120 self.on = true;
121 return .open;
122 }
123 // A popup already up on an empty wall has had this emptiness's
124 // one open, whoever opened it. Left unspent, the Esc that closes
125 // a picker the USER opened is answered by the wall opening it
126 // straight back, and the one-line text costs two Escs.
127 if (is_tty and picking) self.spent = true;
128 return .leave;
129 }
130 // The wall has tiles again, so the next emptiness earns its own open.
131 self.spent = false;
132 // A tile arriving takes the screen back from a popup nobody asked
133 // for — but never out from under a spelling in progress, because
134 // `prompting` layers over `picking` and clearing the lower one
135 // alone leaves an editor eating every key with nothing on screen.
136 if (self.on and !prompting) {
137 self.on = false;
138 return .close;
139 }
140 return .leave;
141 }
142
143 /// The user closed it by hand.
144 pub fn taken(self: *PickerAuto) void {
145 // The next tile to arrive must not force-close a popup they then
146 // open themselves. `spent` is untouched: an Esc on an empty wall
147 // asked for the one-line text, not for the popup again.
148 self.on = false;
149 }
150 };
151
152 /// Whether this action is the picker's. The popup answers every one of
153 /// them, so the arms below it in `run` name them only to stay exhaustive.
154 pub fn isPickAction(a: interact.PrefixFilter.Action) bool {
155 return switch (a) {
156 .pick_open,
157 .pick_move,
158 .pick_select,
159 .pick_birth,
160 .pick_forget,
161 .pick_add_open,
162 .pick_close,
163 => true,
164 else => false,
165 };
166 }
167
168 /// Enter or `c`: a new session on the SELECTED host, tile or no tile.
169 /// Null when nothing was made.
170 pub fn pickBirth(
171 alloc: std.mem.Allocator,
172 tiles: []Tile,
173 present: []bool,
174 live: *usize,
175 shared: *Shared,
176 host_table: []Host,
177 sel: usize,
178 ) ?usize {
179 // The tile creates on attach exactly as a chord-born one does: no side
180 // connection, and no second road onto the wall to keep in step.
181 // A forgotten host is off the rows and out of the file, and its poller
182 // has exited: born on one, a tile no list can ever confirm or vanish
183 // would sit on the wall naming a machine the user has just removed.
184 // `pickerNearest` cannot save this — with every row gone it returns the
185 // selection unchanged.
186 if (sel >= host_table.len or host_table[sel].forgotten.load(.acquire)) {
187 // The one key the footer advertises, on a wall with nothing to
188 // birth on: an Enter that closes the popup and does nothing reads
189 // as a broken key rather than as an empty hosts file.
190 wv.setNotice(shared, "[no hosts to start a session on - a adds one]");
191 return null;
192 }
193 const h = &host_table[sel];
194 // Enter IS the ask, and this copy is where that is written down: the
195 // row it lands on is often the one the poller calls unreachable, and
196 // starting that machine's daemon is what choosing it means. The SPEC
197 // is untouched — the poller re-dials off it every second, and a wall
198 // must not resurrect a daemon whose owner just stopped it.
199 var target = h.spec.target;
200 if (target == .hand) {
201 target.hand.asked = true;
202 // ...and quietly. This dial happens on a tile thread, under the
203 // wall's alternate screen: `muxd start`'s progress would land over
204 // tiles and rails, where the tile's own `connecting` label is
205 // already saying the only thing there is to say.
206 target.hand.quiet = true;
207 }
208 var list_buf: [proto.sessions_text_max]u8 = undefined;
209 h.list_mu.lock();
210 @memcpy(list_buf[0..h.list_len], h.list[0..h.list_len]);
211 const list_len = h.list_len;
212 h.list_mu.unlock();
213 // The daemon's own naming, off the daemon's own list: the name the
214 // `c` chord would have landed on, reached without a pump to ask.
215 var name_buf: [proto.session_name_max]u8 = undefined;
216 const name = client.nextFreeName(&name_buf, list_buf[0..list_len]);
217 const session = alloc.dupe(u8, name) catch return null;
218 const label = wv.tileLabel(alloc, target, session) catch {
219 alloc.free(session);
220 return null;
221 };
222 const anchor = wv.anchorTile(present[0..live.*], shared.sel);
223 const has_anchor = wv.presentCount(present[0..live.*]) > 0;
224 // `-A` is inherited only within one host. A chord inherits it because
225 // the new session is on the machine the offer was already made to; the
226 // picker can cross to a host the user never offered an agent, and a
227 // popup must not be the thing that hands a stranger the keys.
228 const agent = has_anchor and tiles[anchor].host != null and
229 tiles[anchor].host.? == sel and tiles[anchor].r.agent;
230 const at = wv.birthTile(alloc, tiles, present, live, shared, .{
231 .r = .{ .target = target, .label = label, .session = session, .agent = agent },
232 .from = anchor,
233 .place = .beside_focus,
234 .creates = true,
235 // Nowhere to hand a refusal back to on an empty wall, so `keeps_wall`
236 // is what tells `endAction` the wall outlives this tile.
237 .born_from = if (has_anchor) anchor else null,
238 .keeps_wall = true,
239 .host = sel,
240 }) orelse {
241 alloc.free(session);
242 alloc.free(label);
243 wv.setNotice(shared, "[no room on the wall for another session]");
244 return null;
245 };
246 wv.spawnPump(&tiles[at]);
247 // The new session must not wait out a poll to be confirmed by the list
248 // that will also stop the diff from vanishing it.
249 h.poke.store(true, .release);
250 return at;
251 }
252
253 /// `x`: the host leaves the file, its poller stops and its tiles go.
254 pub fn pickForget(
255 alloc: std.mem.Allocator,
256 tiles: []Tile,
257 present: []bool,
258 live: usize,
259 shared: *Shared,
260 host_table: []Host,
261 sel: usize,
262 path: ?[]const u8,
263 ) void {
264 // The SESSIONS keep running: forgetting a daemon is `mux hosts rm`
265 // typed from inside, and that ends nothing.
266 if (sel >= host_table.len) return;
267 const h = &host_table[sel];
268 if (h.forgotten.load(.acquire)) return;
269 // The file first, for `addHost`'s reason: the wall the user is looking
270 // at and the wall they get back next time are the same wall.
271 //
272 // What the file said is what the notice says. A `false` is a line that
273 // was not there to remove — a hand-edited file, or a host this wall took
274 // off argv — and the tiles go either way, so a flat `[forgot ...]` would
275 // leave the wall and the file disagreeing with nobody told.
276 var gone_from_file = true;
277 var why: ?anyerror = null;
278 if (path) |p| {
279 gone_from_file = hosts.forget(alloc, p, h.spec.spelling) catch |e| blk: {
280 why = e;
281 break :blk false;
282 };
283 }
284 h.forgotten.store(true, .release);
285 for (0..live) |i| {
286 if (present[i] and wall_host.ownedBy(&tiles[i], sel))
287 wv.vanishTile(tiles[0..live], present[0..live], shared, i, null);
288 }
289 var buf: [96]u8 = undefined;
290 const said = if (why) |e|
291 std.fmt.bufPrint(&buf, "[hosts file not updated: {s}]", .{hosts.reason(e)}) catch "[hosts file not updated]"
292 else if (!gone_from_file)
293 std.fmt.bufPrint(&buf, "[{s} was not on the wall]", .{h.spec.spelling}) catch "[that host was not on the wall]"
294 else
295 std.fmt.bufPrint(&buf, "[forgot {s}]", .{h.spec.spelling}) catch "[forgot the host]";
296 wv.setNotice(shared, said);
297 }
298
299 /// The popup. Painted by the KEYBOARD thread, which is the only one that
300 /// knows the picker exists: on open, on every key, and on every list that
301 /// lands under it, so the state column is live while the user reads it.
302 ///
303 /// `line` is the spelling editor's text when `a` has it open.
304 pub fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void {
305 if (!shared.is_tty) return;
306 // The flag is set before the lock and READ under it (`tilePaintBegin`),
307 // which is what orders the two: a pump either takes `paint_mu` first and
308 // finishes its rect before this paint starts, or takes it after and sees
309 // the flag already set.
310 shared.picker_open.store(true, .release);
311 shared.paint_mu.lock();
312 defer shared.paint_mu.unlock();
313 const cols = shared.size.cols;
314 const rows = shared.size.rows;
315 const w: u16 = @min(cols, @as(u16, picker_row_max));
316 const left: u16 = (cols -| w) / 2;
317 var body: PickerBody = .{};
318 pickerRows(&body, host_table, sel, w);
319 // Too small for a box is not too small for an answer: the header alone
320 // still says which popup has the keyboard.
321 const cramped = cols < picker_min_cols or rows < picker_min_rows;
322 const want: u16 = @intCast(@min(@as(usize, rows), body.n + 2));
323 const height: u16 = if (cramped) 1 else want;
324 const top: u16 = (rows -| height) / 2;
325 // The window onto the rows when the terminal cannot hold them all: the
326 // selection stays visible, because it is what every other key acts on.
327 const shown: usize = height -| 2;
328 var first: usize = 0;
329 var sel_row: usize = 0;
330 for (body.host[0..body.n], 0..) |hi, i| {
331 if (hi == sel) sel_row = i;
332 }
333 if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1;
334 // The notice wins the footer over the legend and over the editor's own
335 // line: a refusal the user just earned is the one sentence that cannot
336 // wait for the next keystroke.
337 // PEEKED: the notice is part of the frame, and the stamp below can
338 // still refuse to write it. Taken only once the bytes are out.
339 var notice_buf: [96]u8 = undefined;
340 const notice = peekNoticeLocked(shared, &notice_buf);
341 const foot: []const u8 = if (notice.len > 0)
342 notice
343 else if (line) |l|
344 l
345 else
346 " Enter/c new session x forget a add Esc";
347
348 var out: [picker_frame_max]u8 = undefined;
349 var fbs = std.io.fixedBufferStream(&out);
350 const wr = fbs.writer();
351 // The cursor is hidden for as long as the popup owns the screen: a
352 // caret left blinking in a tile says the keys are going there.
353 wr.writeAll("\x1b[?25l") catch return;
354 pickerLine(wr, top, left, w, " hosts");
355 if (!cramped) {
356 var r: u16 = 0;
357 while (r < shown and first + r < body.n) : (r += 1)
358 pickerLine(wr, top + 1 + r, left, w, body.row(first + r));
359 pickerLine(wr, top + height - 1, left, w, foot);
360 }
361 const frame = fbs.getWritten();
362 // Nothing changed, nothing written. The pollers report once a second
363 // per host and every one of them repaints this box; rewriting an
364 // identical screen at that rate is a terminal that never goes quiet.
365 const stamp = std.hash.Wyhash.hash(0, frame);
366 if (stamp == shared.picker_stamp) return;
367 shared.picker_stamp = stamp;
368 shared.notice_len = 0;
369 proto.writeAllFd(shared.out_fd, frame) catch {};
370 }
371
372 /// What the popup owes the screen on a pass with no keystroke: whether to
373 /// paint at all, and the footer to paint with.
374 const PickerRepaint = struct { due: bool, line: ?[]const u8 };
375
376 /// `prompting` is not a term: `relayout` clears the whole screen on any
377 /// poll's birth or vanish while the box is up, and a paint skipped for the
378 /// spelling editor leaves the user typing into an editor the screen no
379 /// longer shows — every key still reaching it.
380 pub fn pickerRepaint(buf: []u8, prefix: *const interact.PrefixFilter, trigger: bool) PickerRepaint {
381 if (!prefix.picking) return .{ .due = false, .line = null };
382 const line: ?[]const u8 = if (prefix.prompting)
383 (std.fmt.bufPrint(buf, ": {s}_", .{prefix.promptLine()}) catch "")
384 else
385 null;
386 return .{ .due = trigger, .line = line };
387 }
388
389 /// One row of the box: reverse video, padded to the box width. The same
390 /// chrome a label bar and a rail wear, so the popup never reads as a
391 /// session's own output.
392 fn pickerLine(wr: anytype, row: u16, left: u16, w: u16, text: []const u8) void {
393 wr.print("\x1b[{d};{d}H\x1b[7m", .{ row + 1, left + 1 }) catch return;
394 const take = @min(text.len, @as(usize, w));
395 wr.writeAll(text[0..take]) catch return;
396 var i: usize = take;
397 while (i < w) : (i += 1) wr.writeByte(' ') catch break;
398 wr.writeAll("\x1b[0m") catch return;
399 }
400
401 pub fn peekNoticeLocked(shared: *const Shared, out: []u8) []const u8 {
402 const n = @min(shared.notice_len, out.len);
403 @memcpy(out[0..n], shared.notice[0..n]);
404 return out[0..n];
405 }
406
407 /// The hosts still on the wall, in file order — the picker's rows, as
408 /// indices into the table a forgotten host never leaves.
409 fn pickerVisible(host_table: []Host, out: *[wv.max_tiles]usize) []const usize {
410 var n: usize = 0;
411 for (host_table, 0..) |*h, hi| {
412 if (h.forgotten.load(.acquire)) continue;
413 out[n] = hi;
414 n += 1;
415 }
416 return out[0..n];
417 }
418
419 /// The host `d` rows away. CLAMPED, not wrapped: a held key that rolled
420 /// the list round would birth on whatever it stopped on.
421 pub fn pickerStep(host_table: []Host, sel: usize, d: i8) usize {
422 var buf: [wv.max_tiles]usize = undefined;
423 const vis = pickerVisible(host_table, &buf);
424 if (vis.len == 0) return sel;
425 var row: usize = 0;
426 for (vis, 0..) |hi, i| {
427 if (hi == sel) row = i;
428 }
429 const moved = @as(isize, @intCast(row)) + d;
430 const clamped: usize = if (moved < 0) 0 else @min(@as(usize, @intCast(moved)), vis.len - 1);
431 return vis[clamped];
432 }
433
434 /// The host a picker row number names, or null when the list is shorter.
435 pub fn pickerAt(host_table: []Host, row: usize) ?usize {
436 var buf: [wv.max_tiles]usize = undefined;
437 const vis = pickerVisible(host_table, &buf);
438 return if (row < vis.len) vis[row] else null;
439 }
440
441 /// Where the selection rests when the picker opens: the wanted host if it
442 /// is still on the wall, else the first row — never a forgotten slot.
443 pub fn pickerNearest(host_table: []Host, want: usize) usize {
444 var buf: [wv.max_tiles]usize = undefined;
445 const vis = pickerVisible(host_table, &buf);
446 for (vis) |hi| {
447 if (hi == want) return want;
448 }
449 return if (vis.len > 0) vis[0] else want;
450 }
451
452 /// The picker's body: one row per host still on the wall, in file order.
453 /// `sel` is a HOST index, not a row — a forgotten host keeps its slot
454 /// (a poller holds the pointer and `Tile.host` indexes it) and only
455 /// leaves the list.
456 pub fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16) void {
457 body.n = 0;
458 // Counted before anything is spelled: the padding is the TABLE's, and a
459 // row cannot know from its own number whether a 10 is coming.
460 var listed: usize = 0;
461 for (host_table) |*h| {
462 if (!h.forgotten.load(.acquire)) listed += 1;
463 }
464 const wide = listed >= 10;
465 for (host_table, 0..) |*h, hi| {
466 if (h.forgotten.load(.acquire)) continue;
467 var state_buf: [24]u8 = undefined;
468 const state = hostState(&state_buf, h);
469 const at = body.n;
470 body.host[at] = hi;
471 body.lens[at] = pickerRow(&body.text[at], at + 1, wide, h.spec.spelling, state, hi == sel, cols).len;
472 body.n += 1;
473 }
474 }
src/tui/wallview.zig
Old New
@@ -37,9 +37,12 @@ const interact = @import("interact");
37 const layout = @import("layout"); 37 const layout = @import("layout");
38 const TmpDir = @import("testtmp").TmpDir; 38 const TmpDir = @import("testtmp").TmpDir;
39 const wall_host = @import("wall_host.zig"); 39 const wall_host = @import("wall_host.zig");
40 const wall_picker = @import("wall_picker.zig");
40 const AddHost = wall_host.AddHost; 41 const AddHost = wall_host.AddHost;
41 const BirthNames = wall_host.BirthNames; 42 const BirthNames = wall_host.BirthNames;
42 const Host = wall_host.Host; 43 const Host = wall_host.Host;
44 const PickerAuto = wall_picker.PickerAuto;
45 const PickerBody = wall_picker.PickerBody;
43 const Resolved = wall_host.Resolved; 46 const Resolved = wall_host.Resolved;
44 const TileIdxs = wall_host.TileIdxs; 47 const TileIdxs = wall_host.TileIdxs;
45 48
@@ -2534,7 +2537,7 @@ fn closeNotice(tiles: []Tile, present: []const bool, shared: *Shared) void {
2534 /// address — so it gets a label that is honest on a bar and is not a 2537 /// address — so it gets a label that is honest on a bar and is not a
2535 /// spelling: `recordHost` refuses it for the same reason. Naming it anyway 2538 /// spelling: `recordHost` refuses it for the same reason. Naming it anyway
2536 /// is better than a blank bar over a session that is really there. 2539 /// is better than a blank bar over a session that is really there.
2537 fn tileLabel(alloc: std.mem.Allocator, target: client.Target, name: []const u8) ![]const u8 { 2540 pub fn tileLabel(alloc: std.mem.Allocator, target: client.Target, name: []const u8) ![]const u8 {
2538 // Sized for the form it will actually take. `spellingCap` bounds the 2541 // Sized for the form it will actually take. `spellingCap` bounds the
2539 // wall grammar's three forms and deliberately counts a `--via` 2542 // wall grammar's three forms and deliberately counts a `--via`
2540 // command's operand as ZERO, because that grammar has no place to put 2543 // command's operand as ZERO, because that grammar has no place to put
@@ -2824,360 +2827,6 @@ pub fn selfSession(target: client.Target, env_sock: ?[]const u8, env_session: ?[
2824 return proto.resolveName(es); 2827 return proto.resolveName(es);
2825 } 2828 }
2826 2829
2827 /// The widest row the picker draws. A spelling past it is cut, never
2828 /// wrapped: a host list that reflows is one a digit cannot address.
2829 const picker_row_max: usize = 128;
2830
2831 /// Under either, the popup is one row: a box that does not fit is worse
2832 /// than a header saying which one it is.
2833 const picker_min_cols: u16 = 24;
2834 const picker_min_rows: u16 = 4;
2835
2836 /// One whole popup, written in one go: every row plus its cursor address
2837 /// and its two SGRs, at the table's cap of rows.
2838 const picker_frame_max: usize = (max_tiles + 2) * (picker_row_max + 32) + 8;
2839
2840 /// The picker's rendered rows. Rendered once per paint and read twice —
2841 /// by the writer and by the tests — so the box on the screen is the box
2842 /// the claims are made about.
2843 const PickerBody = struct {
2844 text: [max_tiles][picker_row_max]u8 = undefined,
2845 lens: [max_tiles]usize = [_]usize{0} ** max_tiles,
2846 /// Which host each row is, so a digit answers in host indices.
2847 host: [max_tiles]usize = [_]usize{0} ** max_tiles,
2848 n: usize = 0,
2849
2850 fn row(self: *const PickerBody, i: usize) []const u8 {
2851 return self.text[i][0..self.lens[i]];
2852 }
2853 };
2854
2855 /// What the state column says: the POLLER's last answer, never the wall's
2856 /// tiles. A host with no tiles is exactly the one the user is about to
2857 /// birth on, and it has to say whether the daemon is answering at all.
2858 fn hostState(buf: []u8, h: *Host) []const u8 {
2859 // `applied` is set by the first list to reach the wall, well or badly,
2860 // so a host that has never reported is `connecting` — not `no
2861 // sessions`, which would send the user to birth on a dead machine.
2862 if (!h.applied) return "connecting";
2863 if (!h.reachable.load(.acquire)) return "unreachable";
2864 var n: usize = 0;
2865 h.list_mu.lock();
2866 var it = std.mem.splitScalar(u8, h.list[0..h.list_len], '\n');
2867 while (it.next()) |name| {
2868 // The same filter `planHostDiff` births through, so the count a row
2869 // advertises is the number of tiles that host can actually put on
2870 // the wall.
2871 if (proto.validSessionName(name)) n += 1;
2872 }
2873 h.list_mu.unlock();
2874 if (n == 0) return "no sessions";
2875 if (n == 1) return "1 session";
2876 return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions";
2877 }
2878
2879 /// One row: ` N> SPELLING STATE `, padded to `cols` so the box has an
2880 /// edge. The spelling is cut from the LEFT, because the head of a socket
2881 /// path is what every daemon on one machine has in common.
2882 fn pickerRow(out: []u8, n: usize, wide: bool, spelling: []const u8, state: []const u8, selected: bool, cols: u16) []const u8 {
2883 // The same two-wide marker a label bar wears, so the eye reads the
2884 // popup and the wall the same way and rows do not shift as it moves.
2885 // `wide` is the number's half of that: an unpadded 10 puts its spelling
2886 // a column right of row 9's.
2887 const marker: []const u8 = if (selected) "> " else " ";
2888 var head_buf: [16]u8 = undefined;
2889 const head = (if (wide)
2890 std.fmt.bufPrint(&head_buf, " {d: >2}{s}", .{ n, marker })
2891 else
2892 std.fmt.bufPrint(&head_buf, " {d}{s}", .{ n, marker })) catch return out[0..0];
2893 const room = @min(@as(usize, cols), out.len);
2894 const field = room -| head.len -| state.len -| 2;
2895 const cut = if (spelling.len > field) spelling[spelling.len - field ..] else spelling;
2896 var w: usize = 0;
2897 for ([_][]const u8{ head, cut }) |part| {
2898 const take = @min(part.len, room - w);
2899 @memcpy(out[w..][0..take], part[0..take]);
2900 w += take;
2901 }
2902 while (w < room -| state.len -| 1 and w < room) : (w += 1) out[w] = ' ';
2903 for ([_][]const u8{ state, " " }) |part| {
2904 const take = @min(part.len, room - w);
2905 @memcpy(out[w..][0..take], part[0..take]);
2906 w += take;
2907 }
2908 return out[0..w];
2909 }
2910
2911 /// Whether the popup on screen is one the WALL opened rather than the user,
2912 /// and what the next pass through the keyboard loop owes it.
2913 const PickerAuto = struct {
2914 /// The popup currently on screen is the WALL's, not the user's.
2915 on: bool = false,
2916 /// This spell of emptiness has already had its one auto-open. Distinct
2917 /// from `on`, and that is the whole of it: the Esc that closes the
2918 /// wall's popup leaves the wall STILL EMPTY, and a single flag would
2919 /// reopen it over the one-line text the Esc asked for.
2920 spent: bool = false,
2921
2922 const Step = enum { open, close, leave };
2923
2924 /// What an empty (or newly un-empty) wall owes the popup.
2925 fn step(self: *PickerAuto, is_tty: bool, empty: bool, picking: bool, prompting: bool) Step {
2926 if (empty) {
2927 if (is_tty and !picking and !self.spent) {
2928 self.spent = true;
2929 self.on = true;
2930 return .open;
2931 }
2932 // A popup already up on an empty wall has had this emptiness's
2933 // one open, whoever opened it. Left unspent, the Esc that closes
2934 // a picker the USER opened is answered by the wall opening it
2935 // straight back, and the one-line text costs two Escs.
2936 if (is_tty and picking) self.spent = true;
2937 return .leave;
2938 }
2939 // The wall has tiles again, so the next emptiness earns its own open.
2940 self.spent = false;
2941 // A tile arriving takes the screen back from a popup nobody asked
2942 // for — but never out from under a spelling in progress, because
2943 // `prompting` layers over `picking` and clearing the lower one
2944 // alone leaves an editor eating every key with nothing on screen.
2945 if (self.on and !prompting) {
2946 self.on = false;
2947 return .close;
2948 }
2949 return .leave;
2950 }
2951
2952 /// The user closed it by hand.
2953 fn taken(self: *PickerAuto) void {
2954 // The next tile to arrive must not force-close a popup they then
2955 // open themselves. `spent` is untouched: an Esc on an empty wall
2956 // asked for the one-line text, not for the popup again.
2957 self.on = false;
2958 }
2959 };
2960
2961 /// Whether this action is the picker's. The popup answers every one of
2962 /// them, so the arms below it in `run` name them only to stay exhaustive.
2963 fn isPickAction(a: interact.PrefixFilter.Action) bool {
2964 return switch (a) {
2965 .pick_open,
2966 .pick_move,
2967 .pick_select,
2968 .pick_birth,
2969 .pick_forget,
2970 .pick_add_open,
2971 .pick_close,
2972 => true,
2973 else => false,
2974 };
2975 }
2976
2977 /// Enter or `c`: a new session on the SELECTED host, tile or no tile.
2978 /// Null when nothing was made.
2979 fn pickBirth(
2980 alloc: std.mem.Allocator,
2981 tiles: []Tile,
2982 present: []bool,
2983 live: *usize,
2984 shared: *Shared,
2985 host_table: []Host,
2986 sel: usize,
2987 ) ?usize {
2988 // The tile creates on attach exactly as a chord-born one does: no side
2989 // connection, and no second road onto the wall to keep in step.
2990 // A forgotten host is off the rows and out of the file, and its poller
2991 // has exited: born on one, a tile no list can ever confirm or vanish
2992 // would sit on the wall naming a machine the user has just removed.
2993 // `pickerNearest` cannot save this — with every row gone it returns the
2994 // selection unchanged.
2995 if (sel >= host_table.len or host_table[sel].forgotten.load(.acquire)) {
2996 // The one key the footer advertises, on a wall with nothing to
2997 // birth on: an Enter that closes the popup and does nothing reads
2998 // as a broken key rather than as an empty hosts file.
2999 setNotice(shared, "[no hosts to start a session on - a adds one]");
3000 return null;
3001 }
3002 const h = &host_table[sel];
3003 // Enter IS the ask, and this copy is where that is written down: the
3004 // row it lands on is often the one the poller calls unreachable, and
3005 // starting that machine's daemon is what choosing it means. The SPEC
3006 // is untouched — the poller re-dials off it every second, and a wall
3007 // must not resurrect a daemon whose owner just stopped it.
3008 var target = h.spec.target;
3009 if (target == .hand) {
3010 target.hand.asked = true;
3011 // ...and quietly. This dial happens on a tile thread, under the
3012 // wall's alternate screen: `muxd start`'s progress would land over
3013 // tiles and rails, where the tile's own `connecting` label is
3014 // already saying the only thing there is to say.
3015 target.hand.quiet = true;
3016 }
3017 var list_buf: [proto.sessions_text_max]u8 = undefined;
3018 h.list_mu.lock();
3019 @memcpy(list_buf[0..h.list_len], h.list[0..h.list_len]);
3020 const list_len = h.list_len;
3021 h.list_mu.unlock();
3022 // The daemon's own naming, off the daemon's own list: the name the
3023 // `c` chord would have landed on, reached without a pump to ask.
3024 var name_buf: [proto.session_name_max]u8 = undefined;
3025 const name = client.nextFreeName(&name_buf, list_buf[0..list_len]);
3026 const session = alloc.dupe(u8, name) catch return null;
3027 const label = tileLabel(alloc, target, session) catch {
3028 alloc.free(session);
3029 return null;
3030 };
3031 const anchor = anchorTile(present[0..live.*], shared.sel);
3032 const has_anchor = presentCount(present[0..live.*]) > 0;
3033 // `-A` is inherited only within one host. A chord inherits it because
3034 // the new session is on the machine the offer was already made to; the
3035 // picker can cross to a host the user never offered an agent, and a
3036 // popup must not be the thing that hands a stranger the keys.
3037 const agent = has_anchor and tiles[anchor].host != null and
3038 tiles[anchor].host.? == sel and tiles[anchor].r.agent;
3039 const at = birthTile(alloc, tiles, present, live, shared, .{
3040 .r = .{ .target = target, .label = label, .session = session, .agent = agent },
3041 .from = anchor,
3042 .place = .beside_focus,
3043 .creates = true,
3044 // Nowhere to hand a refusal back to on an empty wall, so `keeps_wall`
3045 // is what tells `endAction` the wall outlives this tile.
3046 .born_from = if (has_anchor) anchor else null,
3047 .keeps_wall = true,
3048 .host = sel,
3049 }) orelse {
3050 alloc.free(session);
3051 alloc.free(label);
3052 setNotice(shared, "[no room on the wall for another session]");
3053 return null;
3054 };
3055 spawnPump(&tiles[at]);
3056 // The new session must not wait out a poll to be confirmed by the list
3057 // that will also stop the diff from vanishing it.
3058 h.poke.store(true, .release);
3059 return at;
3060 }
3061
3062 /// `x`: the host leaves the file, its poller stops and its tiles go.
3063 fn pickForget(
3064 alloc: std.mem.Allocator,
3065 tiles: []Tile,
3066 present: []bool,
3067 live: usize,
3068 shared: *Shared,
3069 host_table: []Host,
3070 sel: usize,
3071 path: ?[]const u8,
3072 ) void {
3073 // The SESSIONS keep running: forgetting a daemon is `mux hosts rm`
3074 // typed from inside, and that ends nothing.
3075 if (sel >= host_table.len) return;
3076 const h = &host_table[sel];
3077 if (h.forgotten.load(.acquire)) return;
3078 // The file first, for `addHost`'s reason: the wall the user is looking
3079 // at and the wall they get back next time are the same wall.
3080 //
3081 // What the file said is what the notice says. A `false` is a line that
3082 // was not there to remove — a hand-edited file, or a host this wall took
3083 // off argv — and the tiles go either way, so a flat `[forgot ...]` would
3084 // leave the wall and the file disagreeing with nobody told.
3085 var gone_from_file = true;
3086 var why: ?anyerror = null;
3087 if (path) |p| {
3088 gone_from_file = hosts.forget(alloc, p, h.spec.spelling) catch |e| blk: {
3089 why = e;
3090 break :blk false;
3091 };
3092 }
3093 h.forgotten.store(true, .release);
3094 for (0..live) |i| {
3095 if (present[i] and wall_host.ownedBy(&tiles[i], sel))
3096 vanishTile(tiles[0..live], present[0..live], shared, i, null);
3097 }
3098 var buf: [96]u8 = undefined;
3099 const said = if (why) |e|
3100 std.fmt.bufPrint(&buf, "[hosts file not updated: {s}]", .{hosts.reason(e)}) catch "[hosts file not updated]"
3101 else if (!gone_from_file)
3102 std.fmt.bufPrint(&buf, "[{s} was not on the wall]", .{h.spec.spelling}) catch "[that host was not on the wall]"
3103 else
3104 std.fmt.bufPrint(&buf, "[forgot {s}]", .{h.spec.spelling}) catch "[forgot the host]";
3105 setNotice(shared, said);
3106 }
3107
3108 /// The popup. Painted by the KEYBOARD thread, which is the only one that
3109 /// knows the picker exists: on open, on every key, and on every list that
3110 /// lands under it, so the state column is live while the user reads it.
3111 ///
3112 /// `line` is the spelling editor's text when `a` has it open.
3113 fn paintPicker(shared: *Shared, host_table: []Host, sel: usize, line: ?[]const u8) void {
3114 if (!shared.is_tty) return;
3115 // The flag is set before the lock and READ under it (`tilePaintBegin`),
3116 // which is what orders the two: a pump either takes `paint_mu` first and
3117 // finishes its rect before this paint starts, or takes it after and sees
3118 // the flag already set.
3119 shared.picker_open.store(true, .release);
3120 shared.paint_mu.lock();
3121 defer shared.paint_mu.unlock();
3122 const cols = shared.size.cols;
3123 const rows = shared.size.rows;
3124 const w: u16 = @min(cols, @as(u16, picker_row_max));
3125 const left: u16 = (cols -| w) / 2;
3126 var body: PickerBody = .{};
3127 pickerRows(&body, host_table, sel, w);
3128 // Too small for a box is not too small for an answer: the header alone
3129 // still says which popup has the keyboard.
3130 const cramped = cols < picker_min_cols or rows < picker_min_rows;
3131 const want: u16 = @intCast(@min(@as(usize, rows), body.n + 2));
3132 const height: u16 = if (cramped) 1 else want;
3133 const top: u16 = (rows -| height) / 2;
3134 // The window onto the rows when the terminal cannot hold them all: the
3135 // selection stays visible, because it is what every other key acts on.
3136 const shown: usize = height -| 2;
3137 var first: usize = 0;
3138 var sel_row: usize = 0;
3139 for (body.host[0..body.n], 0..) |hi, i| {
3140 if (hi == sel) sel_row = i;
3141 }
3142 if (shown > 0 and sel_row >= shown) first = sel_row - shown + 1;
3143 // The notice wins the footer over the legend and over the editor's own
3144 // line: a refusal the user just earned is the one sentence that cannot
3145 // wait for the next keystroke.
3146 // PEEKED: the notice is part of the frame, and the stamp below can
3147 // still refuse to write it. Taken only once the bytes are out.
3148 var notice_buf: [96]u8 = undefined;
3149 const notice = peekNoticeLocked(shared, &notice_buf);
3150 const foot: []const u8 = if (notice.len > 0)
3151 notice
3152 else if (line) |l|
3153 l
3154 else
3155 " Enter/c new session x forget a add Esc";
3156
3157 var out: [picker_frame_max]u8 = undefined;
3158 var fbs = std.io.fixedBufferStream(&out);
3159 const wr = fbs.writer();
3160 // The cursor is hidden for as long as the popup owns the screen: a
3161 // caret left blinking in a tile says the keys are going there.
3162 wr.writeAll("\x1b[?25l") catch return;
3163 pickerLine(wr, top, left, w, " hosts");
3164 if (!cramped) {
3165 var r: u16 = 0;
3166 while (r < shown and first + r < body.n) : (r += 1)
3167 pickerLine(wr, top + 1 + r, left, w, body.row(first + r));
3168 pickerLine(wr, top + height - 1, left, w, foot);
3169 }
3170 const frame = fbs.getWritten();
3171 // Nothing changed, nothing written. The pollers report once a second
3172 // per host and every one of them repaints this box; rewriting an
3173 // identical screen at that rate is a terminal that never goes quiet.
3174 const stamp = std.hash.Wyhash.hash(0, frame);
3175 if (stamp == shared.picker_stamp) return;
3176 shared.picker_stamp = stamp;
3177 shared.notice_len = 0;
3178 proto.writeAllFd(shared.out_fd, frame) catch {};
3179 }
3180
3181 /// A refusal in THIS client's words. `parseEndReply` hands back the frame's 2830 /// A refusal in THIS client's words. `parseEndReply` hands back the frame's
3182 /// tail unfiltered and `paintBanner` writes it verbatim, so a peer's 2831 /// tail unfiltered and `paintBanner` writes it verbatim, so a peer's
3183 /// `\x1b]0;..\x07` would run outside the replica. The reasons are constants. 2832 /// `\x1b]0;..\x07` would run outside the replica. The reasons are constants.
@@ -3189,117 +2838,13 @@ fn endRefusal(reason: []const u8) []const u8 {
3189 return "[the daemon refused to end this session]"; 2838 return "[the daemon refused to end this session]";
3190 } 2839 }
3191 2840
3192 /// What the popup owes the screen on a pass with no keystroke: whether to
3193 /// paint at all, and the footer to paint with.
3194 const PickerRepaint = struct { due: bool, line: ?[]const u8 };
3195
3196 /// `prompting` is not a term: `relayout` clears the whole screen on any
3197 /// poll's birth or vanish while the box is up, and a paint skipped for the
3198 /// spelling editor leaves the user typing into an editor the screen no
3199 /// longer shows — every key still reaching it.
3200 fn pickerRepaint(buf: []u8, prefix: *const interact.PrefixFilter, trigger: bool) PickerRepaint {
3201 if (!prefix.picking) return .{ .due = false, .line = null };
3202 const line: ?[]const u8 = if (prefix.prompting)
3203 (std.fmt.bufPrint(buf, ": {s}_", .{prefix.promptLine()}) catch "")
3204 else
3205 null;
3206 return .{ .due = trigger, .line = line };
3207 }
3208
3209 /// One row of the box: reverse video, padded to the box width. The same
3210 /// chrome a label bar and a rail wear, so the popup never reads as a
3211 /// session's own output.
3212 fn pickerLine(wr: anytype, row: u16, left: u16, w: u16, text: []const u8) void {
3213 wr.print("\x1b[{d};{d}H\x1b[7m", .{ row + 1, left + 1 }) catch return;
3214 const take = @min(text.len, @as(usize, w));
3215 wr.writeAll(text[0..take]) catch return;
3216 var i: usize = take;
3217 while (i < w) : (i += 1) wr.writeByte(' ') catch break;
3218 wr.writeAll("\x1b[0m") catch return;
3219 }
3220
3221 /// For a caller already holding `paint_mu`. 2841 /// For a caller already holding `paint_mu`.
3222 fn takeNoticeLocked(shared: *Shared, out: []u8) []const u8 { 2842 fn takeNoticeLocked(shared: *Shared, out: []u8) []const u8 {
3223 const said = peekNoticeLocked(shared, out); 2843 const said = wall_picker.peekNoticeLocked(shared, out);
3224 shared.notice_len = 0; 2844 shared.notice_len = 0;
3225 return said; 2845 return said;
3226 } 2846 }
3227 2847
3228 fn peekNoticeLocked(shared: *const Shared, out: []u8) []const u8 {
3229 const n = @min(shared.notice_len, out.len);
3230 @memcpy(out[0..n], shared.notice[0..n]);
3231 return out[0..n];
3232 }
3233
3234 /// The hosts still on the wall, in file order — the picker's rows, as
3235 /// indices into the table a forgotten host never leaves.
3236 fn pickerVisible(host_table: []Host, out: *[max_tiles]usize) []const usize {
3237 var n: usize = 0;
3238 for (host_table, 0..) |*h, hi| {
3239 if (h.forgotten.load(.acquire)) continue;
3240 out[n] = hi;
3241 n += 1;
3242 }
3243 return out[0..n];
3244 }
3245
3246 /// The host `d` rows away. CLAMPED, not wrapped: a held key that rolled
3247 /// the list round would birth on whatever it stopped on.
3248 fn pickerStep(host_table: []Host, sel: usize, d: i8) usize {
3249 var buf: [max_tiles]usize = undefined;
3250 const vis = pickerVisible(host_table, &buf);
3251 if (vis.len == 0) return sel;
3252 var row: usize = 0;
3253 for (vis, 0..) |hi, i| {
3254 if (hi == sel) row = i;
3255 }
3256 const moved = @as(isize, @intCast(row)) + d;
3257 const clamped: usize = if (moved < 0) 0 else @min(@as(usize, @intCast(moved)), vis.len - 1);
3258 return vis[clamped];
3259 }
3260
3261 /// The host a picker row number names, or null when the list is shorter.
3262 fn pickerAt(host_table: []Host, row: usize) ?usize {
3263 var buf: [max_tiles]usize = undefined;
3264 const vis = pickerVisible(host_table, &buf);
3265 return if (row < vis.len) vis[row] else null;
3266 }
3267
3268 /// Where the selection rests when the picker opens: the wanted host if it
3269 /// is still on the wall, else the first row — never a forgotten slot.
3270 fn pickerNearest(host_table: []Host, want: usize) usize {
3271 var buf: [max_tiles]usize = undefined;
3272 const vis = pickerVisible(host_table, &buf);
3273 for (vis) |hi| {
3274 if (hi == want) return want;
3275 }
3276 return if (vis.len > 0) vis[0] else want;
3277 }
3278
3279 /// The picker's body: one row per host still on the wall, in file order.
3280 /// `sel` is a HOST index, not a row — a forgotten host keeps its slot
3281 /// (a poller holds the pointer and `Tile.host` indexes it) and only
3282 /// leaves the list.
3283 fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16) void {
3284 body.n = 0;
3285 // Counted before anything is spelled: the padding is the TABLE's, and a
3286 // row cannot know from its own number whether a 10 is coming.
3287 var listed: usize = 0;
3288 for (host_table) |*h| {
3289 if (!h.forgotten.load(.acquire)) listed += 1;
3290 }
3291 const wide = listed >= 10;
3292 for (host_table, 0..) |*h, hi| {
3293 if (h.forgotten.load(.acquire)) continue;
3294 var state_buf: [24]u8 = undefined;
3295 const state = hostState(&state_buf, h);
3296 const at = body.n;
3297 body.host[at] = hi;
3298 body.lens[at] = pickerRow(&body.text[at], at + 1, wide, h.spec.spelling, state, hi == sel, cols).len;
3299 body.n += 1;
3300 }
3301 }
3302
3303 /// The DIAL is on the main thread, before any wall: ssh can want the tty. 2848 /// The DIAL is on the main thread, before any wall: ssh can want the tty.
3304 /// The pump ADOPTS a link that is already up. 2849 /// The pump ADOPTS a link that is already up.
3305 pub fn runAttach( 2850 pub fn runAttach(
@@ -3809,7 +3354,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3809 picker_opened = true; 3354 picker_opened = true;
3810 picker_shown = true; 3355 picker_shown = true;
3811 input.prefix.picking = true; 3356 input.prefix.picking = true;
3812 picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel); 3357 picker_sel = wall_picker.pickerNearest(host_table[0..hosts_live], picker_sel);
3813 }, 3358 },
3814 .close => { 3359 .close => {
3815 input.prefix.picking = false; 3360 input.prefix.picking = false;
@@ -3831,9 +3376,9 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3831 // a pass with no bell, no news and no key would otherwise leave the 3376 // a pass with no bell, no news and no key would otherwise leave the
3832 // popup wiped until the next list. 3377 // popup wiped until the next list.
3833 var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; 3378 var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
3834 const repair = pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or 3379 const repair = wall_picker.pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or
3835 winch or fds[1].revents != 0 or shared.picker_stamp == 0); 3380 winch or fds[1].revents != 0 or shared.picker_stamp == 0);
3836 if (repair.due) paintPicker(&shared, host_table[0..hosts_live], picker_sel, repair.line); 3381 if (repair.due) wall_picker.paintPicker(&shared, host_table[0..hosts_live], picker_sel, repair.line);
3837 if (fds[0].revents == 0) continue; 3382 if (fds[0].revents == 0) continue;
3838 3383
3839 const n = std.posix.read(stdin_fd, &b) catch break; 3384 const n = std.posix.read(stdin_fd, &b) catch break;
@@ -3850,7 +3395,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3850 // so neither the empty wall's arms below nor the tiles' ever see 3395 // so neither the empty wall's arms below nor the tiles' ever see
3851 // one — and the popup is the one thing that works on a wall with 3396 // one — and the popup is the one thing that works on a wall with
3852 // no tile at all, which is why it is answered ahead of both. 3397 // no tile at all, which is why it is answered ahead of both.
3853 if (input.prefix.picking or isPickAction(cmd.action)) { 3398 if (input.prefix.picking or wall_picker.isPickAction(cmd.action)) {
3854 // Typed AT the session, ahead of the chord, in the same read. 3399 // Typed AT the session, ahead of the chord, in the same read.
3855 if (cmd.forward.len > 0 and z < live and present[z]) sendKeys(&tiles[z], cmd.forward); 3400 if (cmd.forward.len > 0 and z < live and present[z]) sendKeys(&tiles[z], cmd.forward);
3856 var birth_at: ?usize = null; 3401 var birth_at: ?usize = null;
@@ -3862,14 +3407,14 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3862 if (z < live and present[z]) { 3407 if (z < live and present[z]) {
3863 if (tiles[z].host) |hi| picker_sel = hi; 3408 if (tiles[z].host) |hi| picker_sel = hi;
3864 } 3409 }
3865 picker_sel = pickerNearest(host_table[0..hosts_live], picker_sel); 3410 picker_sel = wall_picker.pickerNearest(host_table[0..hosts_live], picker_sel);
3866 } 3411 }
3867 switch (cmd.action) { 3412 switch (cmd.action) {
3868 .pick_move => |d| picker_sel = pickerStep(host_table[0..hosts_live], picker_sel, d), 3413 .pick_move => |d| picker_sel = wall_picker.pickerStep(host_table[0..hosts_live], picker_sel, d),
3869 .pick_select => |row| { 3414 .pick_select => |row| {
3870 if (pickerAt(host_table[0..hosts_live], row - 1)) |hi| picker_sel = hi; 3415 if (wall_picker.pickerAt(host_table[0..hosts_live], row - 1)) |hi| picker_sel = hi;
3871 }, 3416 },
3872 .pick_birth => birth_at = pickBirth( 3417 .pick_birth => birth_at = wall_picker.pickBirth(
3873 alloc, 3418 alloc,
3874 tiles, 3419 tiles,
3875 present, 3420 present,
@@ -3878,7 +3423,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3878 host_table[0..hosts_live], 3423 host_table[0..hosts_live],
3879 picker_sel, 3424 picker_sel,
3880 ), 3425 ),
3881 .pick_forget => pickForget( 3426 .pick_forget => wall_picker.pickForget(
3882 alloc, 3427 alloc,
3883 tiles, 3428 tiles,
3884 present, 3429 present,
@@ -3916,8 +3461,8 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
3916 picker_shown = input.prefix.picking; 3461 picker_shown = input.prefix.picking;
3917 if (input.prefix.picking) { 3462 if (input.prefix.picking) {
3918 var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; 3463 var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
3919 const keyed = pickerRepaint(&line_buf, &input.prefix, true); 3464 const keyed = wall_picker.pickerRepaint(&line_buf, &input.prefix, true);
3920 paintPicker(&shared, host_table[0..hosts_live], picker_sel, keyed.line); 3465 wall_picker.paintPicker(&shared, host_table[0..hosts_live], picker_sel, keyed.line);
3921 } else { 3466 } else {
3922 // The close gives the terminal back: `relayout` clears it 3467 // The close gives the terminal back: `relayout` clears it
3923 // and bumps `repaint_gen`, which is the only thing that 3468 // and bumps `repaint_gen`, which is the only thing that
@@ -4499,7 +4044,7 @@ test "applyReadyLists: a host added after the wall opened gets its sessions, and
4499 // A picker painted into a pipe, drained. Non-blocking on both ends so a 4044 // A picker painted into a pipe, drained. Non-blocking on both ends so a
4500 // frame that outgrew the pipe FAILS here rather than parking the suite. 4045 // frame that outgrew the pipe FAILS here rather than parking the suite.
4501 fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 { 4046 fn pickerFrame(shared: *Shared, r: std.posix.fd_t, host_table: []Host, sel: usize, out: []u8) []const u8 {
4502 paintPicker(shared, host_table, sel, null); 4047 wall_picker.paintPicker(shared, host_table, sel, null);
4503 const n = std.posix.read(r, out) catch 0; 4048 const n = std.posix.read(r, out) catch 0;
4504 return out[0..n]; 4049 return out[0..n];
4505 } 4050 }
@@ -4606,13 +4151,13 @@ test "paintPicker: a frame the stamp refuses to write does not eat the notice wi
4606 var table = [_]Host{testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")}; 4151 var table = [_]Host{testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock")};
4607 setList(&table[0], ""); 4152 setList(&table[0], "");
4608 table[0].applied = true; 4153 table[0].applied = true;
4609 paintPicker(&shared, &table, 0, null); 4154 wall_picker.paintPicker(&shared, &table, 0, null);
4610 4155
4611 // A notice whose footer is byte-for-byte the legend already on the 4156 // A notice whose footer is byte-for-byte the legend already on the
4612 // screen: the stamp refuses the write, and taking the notice ahead of 4157 // screen: the stamp refuses the write, and taking the notice ahead of
4613 // that check is a sentence consumed by a frame nobody was sent. 4158 // that check is a sentence consumed by a frame nobody was sent.
4614 setNotice(&shared, " Enter/c new session x forget a add Esc"); 4159 setNotice(&shared, " Enter/c new session x forget a add Esc");
4615 paintPicker(&shared, &table, 0, null); 4160 wall_picker.paintPicker(&shared, &table, 0, null);
4616 var buf: [96]u8 = undefined; 4161 var buf: [96]u8 = undefined;
4617 try std.testing.expectEqualStrings( 4162 try std.testing.expectEqualStrings(
4618 " Enter/c new session x forget a add Esc", 4163 " Enter/c new session x forget a add Esc",
@@ -4631,7 +4176,7 @@ test "paintPicker: replayed into an engine, the popup covers its box and NOT one
4631 // top = (20-5)/2. A grep over the frame's CUP sequences cannot see a 4176 // top = (20-5)/2. A grep over the frame's CUP sequences cannot see a
4632 // paint that reaches a cell by another route — a different CUP form, a 4177 // paint that reaches a cell by another route — a different CUP form, a
4633 // wider pad — so this one judges the GRID. 4178 // wider pad — so this one judges the GRID.
4634 const w: u16 = @intCast(picker_row_max); 4179 const w: u16 = @intCast(wall_picker.picker_row_max);
4635 const left: u16 = (cols - w) / 2; 4180 const left: u16 = (cols - w) / 2;
4636 const height: u16 = 5; 4181 const height: u16 = 5;
4637 const top: u16 = (rows - height) / 2; 4182 const top: u16 = (rows - height) / 2;
@@ -4657,7 +4202,7 @@ test "paintPicker: replayed into an engine, the popup covers its box and NOT one
4657 setList(h, ""); 4202 setList(h, "");
4658 h.applied = true; 4203 h.applied = true;
4659 } 4204 }
4660 paintPicker(&shared, &table, 1, null); 4205 wall_picker.paintPicker(&shared, &table, 1, null);
4661 screen.drain(); 4206 screen.drain();
4662 const dump = try screen.eng.dumpPlain(alloc); 4207 const dump = try screen.eng.dumpPlain(alloc);
4663 defer alloc.free(dump); 4208 defer alloc.free(dump);
@@ -4907,8 +4452,8 @@ test "tilePaintBegin: no tile paints while the picker owns the screen" {
4907 test "pickerRow: a table with a 10 in it pads every number, so no spelling shifts a column" { 4452 test "pickerRow: a table with a 10 in it pads every number, so no spelling shifts a column" {
4908 var nine_buf: [96]u8 = undefined; 4453 var nine_buf: [96]u8 = undefined;
4909 var ten_buf: [96]u8 = undefined; 4454 var ten_buf: [96]u8 = undefined;
4910 const nine = pickerRow(&nine_buf, 9, true, "--sock /a", "1 session", false, 40); 4455 const nine = wall_picker.pickerRow(&nine_buf, 9, true, "--sock /a", "1 session", false, 40);
4911 const ten = pickerRow(&ten_buf, 10, true, "--sock /a", "1 session", false, 40); 4456 const ten = wall_picker.pickerRow(&ten_buf, 10, true, "--sock /a", "1 session", false, 40);
4912 try std.testing.expectEqual( 4457 try std.testing.expectEqual(
4913 std.mem.indexOf(u8, nine, "--sock").?, 4458 std.mem.indexOf(u8, nine, "--sock").?,
4914 std.mem.indexOf(u8, ten, "--sock").?, 4459 std.mem.indexOf(u8, ten, "--sock").?,
@@ -4917,7 +4462,7 @@ test "pickerRow: a table with a 10 in it pads every number, so no spelling shift
4917 // A table that never reaches 10 spends no column on a digit it has not 4462 // A table that never reaches 10 spends no column on a digit it has not
4918 // got: the marker is already two wide for the same reason. 4463 // got: the marker is already two wide for the same reason.
4919 var narrow_buf: [96]u8 = undefined; 4464 var narrow_buf: [96]u8 = undefined;
4920 const narrow = pickerRow(&narrow_buf, 9, false, "--sock /a", "1 session", false, 40); 4465 const narrow = wall_picker.pickerRow(&narrow_buf, 9, false, "--sock /a", "1 session", false, 40);
4921 try std.testing.expectEqualStrings(" 9 --sock", narrow[0..10]); 4466 try std.testing.expectEqualStrings(" 9 --sock", narrow[0..10]);
4922 } 4467 }
4923 4468
@@ -4943,7 +4488,7 @@ test "pickerRows: every host says what its poller last answered" {
4943 // two send the user to opposite places. 4488 // two send the user to opposite places.
4944 4489
4945 var body: PickerBody = .{}; 4490 var body: PickerBody = .{};
4946 pickerRows(&body, &table, 2, 48); 4491 wall_picker.pickerRows(&body, &table, 2, 48);
4947 4492
4948 try std.testing.expectEqual(@as(usize, 4), body.n); 4493 try std.testing.expectEqual(@as(usize, 4), body.n);
4949 try std.testing.expectEqualStrings(" 1 --sock /tmp/a.sock 3 sessions ", body.row(0)); 4494 try std.testing.expectEqualStrings(" 1 --sock /tmp/a.sock 3 sessions ", body.row(0));
@@ -4959,7 +4504,7 @@ test "pickerRows: one session is not 1 sessions" {
4959 setList(&table[0], "0\n"); 4504 setList(&table[0], "0\n");
4960 table[0].applied = true; 4505 table[0].applied = true;
4961 var body: PickerBody = .{}; 4506 var body: PickerBody = .{};
4962 pickerRows(&body, &table, 0, 32); 4507 wall_picker.pickerRows(&body, &table, 0, 32);
4963 try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0)); 4508 try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0));
4964 } 4509 }
4965 4510
@@ -4973,7 +4518,7 @@ test "pickerRows: a spelling wider than the box keeps its tail" {
4973 setList(&table[0], "0\n"); 4518 setList(&table[0], "0\n");
4974 table[0].applied = true; 4519 table[0].applied = true;
4975 var body: PickerBody = .{}; 4520 var body: PickerBody = .{};
4976 pickerRows(&body, &table, 0, 32); 4521 wall_picker.pickerRows(&body, &table, 0, 32);
4977 // Cut from the LEFT: the head of a socket path is what every host on 4522 // Cut from the LEFT: the head of a socket path is what every host on
4978 // one machine has in common, and the tail is what tells them apart. 4523 // one machine has in common, and the tail is what tells them apart.
4979 try std.testing.expectEqualStrings(" 1> path/to/muxd.sock 1 session ", body.row(0)); 4524 try std.testing.expectEqualStrings(" 1> path/to/muxd.sock 1 session ", body.row(0));
@@ -5002,7 +4547,7 @@ test "pickBirth: Enter on an emptied popup births nothing, not a session on a ho
5002 h.forgotten.store(true, .release); 4547 h.forgotten.store(true, .release);
5003 } 4548 }
5004 4549
5005 const at = pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1); 4550 const at = wall_picker.pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1);
5006 4551
5007 if (at != null) return error.BornOnAForgottenHost; 4552 if (at != null) return error.BornOnAForgottenHost;
5008 if (live != 0) return error.AForgottenHostTookASlot; 4553 if (live != 0) return error.AForgottenHostTookASlot;
@@ -5040,7 +4585,7 @@ test "pickBirth: Enter is an ask — the tile it births may start a daemon, the
5040 h.applied = true; 4585 h.applied = true;
5041 } 4586 }
5042 4587
5043 const at = pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1) orelse 4588 const at = wall_picker.pickBirth(alloc, &tiles, &present, &live, &shared, &table, 1) orelse
5044 return error.EnterBornNothing; 4589 return error.EnterBornNothing;
5045 4590
5046 // The row Enter lands on is often exactly the one the poller calls 4591 // The row Enter lands on is often exactly the one the poller calls
@@ -5067,7 +4612,7 @@ test "pickerRows: a forgotten host is off the list, and the numbers close up" {
5067 // indexes it — so only the list closes up. 4612 // indexes it — so only the list closes up.
5068 table[1].forgotten.store(true, .release); 4613 table[1].forgotten.store(true, .release);
5069 var body: PickerBody = .{}; 4614 var body: PickerBody = .{};
5070 pickerRows(&body, &table, 2, 32); 4615 wall_picker.pickerRows(&body, &table, 2, 32);
5071 try std.testing.expectEqual(@as(usize, 2), body.n); 4616 try std.testing.expectEqual(@as(usize, 2), body.n);
5072 try std.testing.expectEqualStrings(" 1 a no sessions ", body.row(0)); 4617 try std.testing.expectEqualStrings(" 1 a no sessions ", body.row(0));
5073 try std.testing.expectEqualStrings(" 2> c no sessions ", body.row(1)); 4618 try std.testing.expectEqualStrings(" 2> c no sessions ", body.row(1));
@@ -5497,10 +5042,10 @@ test "pickerRepaint: a screen cleared under the spelling editor still owes a pai
5497 var buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; 5042 var buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
5498 5043
5499 // Nothing is owed while the popup is not up, whatever else happened. 5044 // Nothing is owed while the popup is not up, whatever else happened.
5500 try std.testing.expect(!pickerRepaint(&buf, &f, true).due); 5045 try std.testing.expect(!wall_picker.pickerRepaint(&buf, &f, true).due);
5501 5046
5502 f.picking = true; 5047 f.picking = true;
5503 const closed = pickerRepaint(&buf, &f, false); 5048 const closed = wall_picker.pickerRepaint(&buf, &f, false);
5504 try std.testing.expect(!closed.due); 5049 try std.testing.expect(!closed.due);
5505 try std.testing.expectEqual(@as(?[]const u8, null), closed.line); 5050 try std.testing.expectEqual(@as(?[]const u8, null), closed.line);
5506 5051
@@ -5511,7 +5056,7 @@ test "pickerRepaint: a screen cleared under the spelling editor still owes a pai
5511 f.prompting = true; 5056 f.prompting = true;
5512 var typed = [_]u8{ 'b', 'o', 'x' }; 5057 var typed = [_]u8{ 'b', 'o', 'x' };
5513 for (&typed) |*c| _ = f.feed(c[0..1]); 5058 for (&typed) |*c| _ = f.feed(c[0..1]);
5514 const repair = pickerRepaint(&buf, &f, true); 5059 const repair = wall_picker.pickerRepaint(&buf, &f, true);
5515 try std.testing.expect(repair.due); 5060 try std.testing.expect(repair.due);
5516 try std.testing.expectEqualStrings(": box_", repair.line orelse ""); 5061 try std.testing.expectEqualStrings(": box_", repair.line orelse "");
5517 } 5062 }
@@ -5526,13 +5071,13 @@ test "hostState: the row counts the sessions the wall could show, not the runs i
5526 5071
5527 // Never polled, so nothing is known: `no sessions` here would send the 5072 // Never polled, so nothing is known: `no sessions` here would send the
5528 // user to birth on a machine that is not answering. 5073 // user to birth on a machine that is not answering.
5529 try std.testing.expectEqualStrings("connecting", hostState(&buf, &h)); 5074 try std.testing.expectEqualStrings("connecting", wall_picker.hostState(&buf, &h));
5530 5075
5531 h.applied = true; 5076 h.applied = true;
5532 const listed = "a\nb\nc\n"; 5077 const listed = "a\nb\nc\n";
5533 @memcpy(h.list[0..listed.len], listed); 5078 @memcpy(h.list[0..listed.len], listed);
5534 h.list_len = listed.len; 5079 h.list_len = listed.len;
5535 try std.testing.expectEqualStrings("3 sessions", hostState(&buf, &h)); 5080 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
5536 5081
5537 // A row saying `4 sessions` beside three tiles is the row lying about 5082 // A row saying `4 sessions` beside three tiles is the row lying about
5538 // the wall: `planHostDiff` births through `validSessionName`, so the 5083 // the wall: `planHostDiff` births through `validSessionName`, so the
@@ -5540,10 +5085,10 @@ test "hostState: the row counts the sessions the wall could show, not the runs i
5540 const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n"; 5085 const with_junk = "a\nb\n" ++ ("x" ** (proto.session_name_max + 1)) ++ "\nc\n";
5541 @memcpy(h.list[0..with_junk.len], with_junk); 5086 @memcpy(h.list[0..with_junk.len], with_junk);
5542 h.list_len = with_junk.len; 5087 h.list_len = with_junk.len;
5543 try std.testing.expectEqualStrings("3 sessions", hostState(&buf, &h)); 5088 try std.testing.expectEqualStrings("3 sessions", wall_picker.hostState(&buf, &h));
5544 5089
5545 h.reachable.store(false, .release); 5090 h.reachable.store(false, .release);
5546 try std.testing.expectEqualStrings("unreachable", hostState(&buf, &h)); 5091 try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h));
5547 } 5092 }
5548 5093
5549 test "addHost: a forgotten host's slot comes back, so a and x cannot fill the table between them" { 5094 test "addHost: a forgotten host's slot comes back, so a and x cannot fill the table between them" {