a73x

c48a660c

feat: Ctrl-\ : adds a tile by spelling from inside the wall

a73x   2026-08-25 19:32

Commit message
feat: Ctrl-\ : adds a tile by spelling from inside the wall

A chord-born tile inherits the focused tile's daemon, so growing the
wall by a HOST meant leaving it: Ctrl-\ d, mux HOST, Ctrl-\ w. The
prompt is argv typed from inside — the spelling is born with mux
TARGET's row of the birth table (creates, records, no -A) beside the
focus, placed by the user like every other chord. The prompt line is
painted from the keyboard through a tile-bounded banner; leaving it is
a re-claim of the focus, which is also how a refusal's notice lands.

CLAUDE.md
Old New
@@ -66,7 +66,10 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`.
66 on the wire: the keyboard writes the outgoing tile's `session_release` 66 on the wire: the keyboard writes the outgoing tile's `session_release`
67 under `paint_mu`, then doorbells the old pump (release) and the new 67 under `paint_mu`, then doorbells the old pump (release) and the new
68 (claim). `mux [TARGET]` is a wall of one tile whose rect is the whole 68 (claim). `mux [TARGET]` is a wall of one tile whose rect is the whole
69 terminal — ONE interaction loop, a tile pump. 69 terminal — ONE interaction loop, a tile pump. A chord-born tile
70 inherits the focused tile's target; `Ctrl-\ :` is the one chord that
71 takes a spelling, born with argv's row of the birth table (creates,
72 records, no `-A`) through the same `birthTile`.
70 - **Resize is gain-only.** `layout.Tree.resize` never shrinks a pane; the 73 - **Resize is gain-only.** `layout.Tree.resize` never shrinks a pane; the
71 shrink keys grow a neighbor at the focus's expense in `wallview.doResize`. 74 shrink keys grow a neighbor at the focus's expense in `wallview.doResize`.
72 - **Rails are painted from `relayout` and tiles cannot reach them.** Every 75 - **Rails are painted from `relayout` and tiles cannot reach them.** Every
README.md
Old New
@@ -74,6 +74,7 @@ The focused tile has the terminal, and only the prefix is held back:
74 | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow the focus's width, `k`/`j` its height, one cell per press; Esc leaves silently, any other key leaves and types | 74 | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow the focus's width, `k`/`j` its height, one cell per press; Esc leaves silently, any other key leaves and types |
75 | `Ctrl-\` `1`-`9` | focus tile N | 75 | `Ctrl-\` `1`-`9` | focus tile N |
76 | `Ctrl-\` `x` | forget the focused tile (off the wall file; the session keeps running) | 76 | `Ctrl-\` `x` | forget the focused tile (off the wall file; the session keeps running) |
77 | `Ctrl-\` `:` | add a tile by spelling: type `HOST#SESSION`, `quic://HOST#SESSION` or `--sock PATH#SESSION`, Enter adds it beside the focus (creating the session if needed) and records it; Esc or Ctrl-C cancels. The line editor takes printable ASCII only, so a path or hostname with any other byte in it goes in the wall file or on argv instead |
77 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | 78 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) |
78 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) | 79 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) |
79 | drag with the left button | select what it crosses, copy on release | 80 | drag with the left button | select what it crosses, copy on release |
src/interact.zig
Old New
@@ -119,6 +119,14 @@ pub const PrefixFilter = struct {
119 /// A longer one is cut at the cap, not refused. 119 /// A longer one is cut at the cap, not refused.
120 pub const prompt_max: usize = 256; 120 pub const prompt_max: usize = 256;
121 121
122 // The banner buffer must hold a whole prompt line — the `": "` and the
123 // cursor `"_"` on top of the spelling — and a comment cannot fail a
124 // build. Asserted here because the import only runs this way: interact
125 // is layer 2 and paint is layer 1.
126 comptime {
127 std.debug.assert(prompt_max + ": ".len + "_".len <= paint_mod.banner_label_max);
128 }
129
122 /// A prefix arrived at the end of a read and its command key has not 130 /// A prefix arrived at the end of a read and its command key has not
123 /// been typed yet. Held across reads so a chord split by a read 131 /// been typed yet. Held across reads so a chord split by a read
124 /// boundary is still one chord. 132 /// boundary is still one chord.
src/mux_main.zig
Old New
@@ -40,7 +40,8 @@ const usage =
40 \\ mux wall [SPELLING...] shows several sessions at once, one stripe 40 \\ mux wall [SPELLING...] shows several sessions at once, one stripe
41 \\ each; `Ctrl-\ 1-9` focuses a tile and types into it, `Ctrl-\ h/j/k/l` 41 \\ each; `Ctrl-\ 1-9` focuses a tile and types into it, `Ctrl-\ h/j/k/l`
42 \\ moves between panes, `Ctrl-\ |/-` split right/below, `Ctrl-\ f` 42 \\ moves between panes, `Ctrl-\ |/-` split right/below, `Ctrl-\ f`
43 \\ fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ d` leaves. SPELLING is 43 \\ fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ :` adds a tile by
44 \\ spelling (Enter adds, Esc cancels), `Ctrl-\ d` leaves. SPELLING is
44 \\ the wall grammar 45 \\ the wall grammar
45 \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION], 46 \\ (HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION],
46 \\ one argument per tile, but `--sock PATH` may also be two arguments 47 \\ one argument per tile, but `--sock PATH` may also be two arguments
@@ -402,7 +403,9 @@ pub fn main() !u8 {
402 // mistake this catches. It sits on the USER's attach only — 403 // mistake this catches. It sits on the USER's attach only —
403 // the Ctrl-\ chords grow their tiles from inside the wall and 404 // the Ctrl-\ chords grow their tiles from inside the wall and
404 // never come back through this switch, so focusing from session 405 // never come back through this switch, so focusing from session
405 // 0 to session 1 keeps working. 406 // 0 to session 1 keeps working. `Ctrl-\ :` is the one chord
407 // that takes a spelling, and it runs `wallview.showsSelf`
408 // itself.
406 if (insideThisSession( 409 if (insideThisSession(
407 std.posix.getenv(proto.sock_env), 410 std.posix.getenv(proto.sock_env),
408 std.posix.getenv(proto.session_env), 411 std.posix.getenv(proto.session_env),
src/paint.zig
Old New
@@ -194,13 +194,23 @@ fn bannerText(buf: []u8, view_cols: u16, label: []const u8, row_off: u16, col_of
194 return std.fmt.bufPrint(buf, "\x1b[{d};{d}H\x1b[7m{s}\x1b[0m", .{ row_off + 1, col, label }); 194 return std.fmt.bufPrint(buf, "\x1b[{d};{d}H\x1b[7m{s}\x1b[0m", .{ row_off + 1, col, label });
195 } 195 }
196 196
197 // The longest label `paintBanner` will render whole. It holds the wall's
198 // add-tile prompt line whole — `interact.PrefixFilter.prompt_max` plus its
199 // `": "` and `"_"` — which interact asserts at comptime, that import being
200 // the one that runs the right way.
201 // Below this the banner is best-effort; above it, `bufPrint` overflows and
202 // the caller's status marker silently never reaches the screen.
203 pub const banner_label_max: usize = 260;
204
197 /// Drop a banner onto a screen that is otherwise staying put — the cursor is 205 /// Drop a banner onto a screen that is otherwise staying put — the cursor is
198 /// saved and restored around it, so the shell's cursor does not visibly jump 206 /// saved and restored around it, so the shell's cursor does not visibly jump
199 /// to the corner. Best-effort: a status marker is never worth failing over. 207 /// to the corner. Best-effort: a status marker is never worth failing over.
200 pub fn paintBanner(out_fd: std.posix.fd_t, view_cols: u16, label: []const u8, row_off: u16, col_off: u16) void { 208 pub fn paintBanner(out_fd: std.posix.fd_t, view_cols: u16, label: []const u8, row_off: u16, col_off: u16) void {
201 var buf: [96]u8 = undefined; 209 // Both sized off the cap: 24 covers `\x1b[R;CH` at u16 width plus the
210 // two SGRs, and the wrap adds six more.
211 var buf: [banner_label_max + 24]u8 = undefined;
202 const mark = bannerText(&buf, view_cols, label, row_off, col_off) catch return; 212 const mark = bannerText(&buf, view_cols, label, row_off, col_off) catch return;
203 var paint: [128]u8 = undefined; 213 var paint: [banner_label_max + 32]u8 = undefined;
204 const text = std.fmt.bufPrint(&paint, "\x1b[s{s}\x1b[u", .{mark}) catch return; 214 const text = std.fmt.bufPrint(&paint, "\x1b[s{s}\x1b[u", .{mark}) catch return;
205 proto.writeAllFd(out_fd, text) catch {}; 215 proto.writeAllFd(out_fd, text) catch {};
206 } 216 }
@@ -333,6 +343,45 @@ test "paintBanner on a narrow tty clamps to column 1 instead of underflowing" {
333 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[1;1H") != null); 343 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[1;1H") != null);
334 } 344 }
335 345
346 test "paint: a banner label of banner_label_max bytes still paints" {
347 // The wall's add-tile prompt is a banner, and a spelling the user is
348 // mid-way through typing is a label. A buffer that overflows on a long
349 // one does not truncate — it writes NOTHING, so the prompt appears to
350 // freeze at the width the buffer happened to allow.
351 var label: [banner_label_max]u8 = undefined;
352 @memset(&label, 'x');
353 const pipe = try std.posix.pipe();
354 defer std.posix.close(pipe[0]);
355 paintBanner(pipe[1], 400, &label, 0, 0);
356 std.posix.close(pipe[1]);
357
358 var out: [1024]u8 = undefined;
359 const n = try std.posix.read(pipe[0], &out);
360 try std.testing.expect(std.mem.indexOf(u8, out[0..n], &label) != null);
361 }
362
363 test "paint: a banner never starts left of its pane" {
364 // Right-alignment is a subtraction, and a label wider than the pane
365 // would take it left of `col_off` — into the neighbour across the rail.
366 // `bannerText` does not truncate: keeping a label INSIDE its pane's
367 // right edge is the caller's, and `wallview.tileBanner` owns it.
368 var buf: [banner_label_max + 24]u8 = undefined;
369 var label: [50]u8 = undefined;
370 @memset(&label, 'x');
371 // Exactly the pane's width: the right edge and the left edge coincide.
372 try std.testing.expectEqual(@as(u16, 61), try bannerCol(&buf, 40, label[0..40], 60));
373 // Wider than the pane: the floor holds it at the pane's own left edge.
374 try std.testing.expectEqual(@as(u16, 61), try bannerCol(&buf, 40, label[0..50], 60));
375 }
376
377 /// The column `bannerText` chose, parsed back out of its `\x1b[R;CH`.
378 fn bannerCol(buf: []u8, view_cols: u16, label: []const u8, col_off: u16) !u16 {
379 const text = try bannerText(buf, view_cols, label, 0, col_off);
380 const semi = std.mem.indexOfScalar(u8, text, ';').?;
381 const h = std.mem.indexOfScalar(u8, text, 'H').?;
382 return std.fmt.parseInt(u16, text[semi + 1 .. h], 10);
383 }
384
336 test "paintBanner parks at row_off+1" { 385 test "paintBanner parks at row_off+1" {
337 // The banner's row is row_off+1: a tile painting at an offset parks its 386 // The banner's row is row_off+1: a tile painting at an offset parks its
338 // status marker in its own top-right corner, not the screen's. 387 // status marker in its own top-right corner, not the screen's.
src/wall.zig
Old New
@@ -70,6 +70,11 @@ pub fn parseSpelling(line: []const u8) ParseError!Parsed {
70 70
71 pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error; 71 pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error;
72 72
73 /// Every mouth's rule: a target starting with a dash is a mistyped flag.
74 pub fn flagLike(target: []const u8) bool {
75 return target.len > 0 and target[0] == '-' and !std.mem.startsWith(u8, target, "--sock ");
76 }
77
73 /// Both binaries accept both `--sock` dialects: one parser, one spelling. 78 /// Both binaries accept both `--sock` dialects: one parser, one spelling.
74 pub fn spellingFromArgv( 79 pub fn spellingFromArgv(
75 alloc: std.mem.Allocator, 80 alloc: std.mem.Allocator,
@@ -84,12 +89,12 @@ pub fn spellingFromArgv(
84 } 89 }
85 // A wall takes targets, and no target starts with a dash: bare `--sock` 90 // A wall takes targets, and no target starts with a dash: bare `--sock`
86 // took its path and returned above, and the one-piece `--sock PATH` 91 // took its path and returned above, and the one-piece `--sock PATH`
87 // dialect is exempted on the next line. Left to fall through, `mux wall 92 // dialect is `flagLike`'s exemption. Left to fall through, `mux wall
88 // -A host` became a tile for a host named `-A` and failed to resolve 93 // -A host` became a tile for a host named `-A` and failed to resolve
89 // somewhere far from the typo — and a wall has no per-tile agent flag 94 // somewhere far from the typo — and a wall has no per-tile agent flag
90 // to have meant, `-A` belonging to a single attach. 95 // to have meant, `-A` belonging to a single attach. Shared with the
91 if (args[i].len > 0 and args[i][0] == '-' and 96 // prompt, which is this argv typed from inside a running wall.
92 !std.mem.startsWith(u8, args[i], "--sock ")) return error.FlagLikeTarget; 97 if (flagLike(args[i])) return error.FlagLikeTarget;
93 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 }; 98 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 };
94 } 99 }
95 100
src/wallview.zig
Old New
@@ -626,6 +626,40 @@ fn wallBanner(shared: *Shared, text: []const u8, row_off: u16) void {
626 paint.paintBanner(shared.out_fd, shared.size.cols, text, row_off, 0); 626 paint.paintBanner(shared.out_fd, shared.size.cols, text, row_off, 0);
627 } 627 }
628 628
629 /// `wallBanner` bounded by ONE tile's rect: a prompt wider than a
630 /// left-hand pane must not cross the rail into its neighbour.
631 fn tileBanner(t: *Tile, text: []const u8) void {
632 if (!t.shared.is_tty) return;
633 // The tail, so the cursor end of a long spelling is what is on screen.
634 // Bounded by the paint's own cap as well as the rect: a label past it
635 // is not truncated by `paintBanner`, it is dropped whole.
636 const room = @min(@as(usize, t.rect.cols), paint.banner_label_max);
637 const shown = if (text.len > room) text[text.len - room ..] else text;
638 t.shared.paint_mu.lock();
639 defer t.shared.paint_mu.unlock();
640 paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left);
641 }
642
643 /// `tileBanner` undone. The keyboard paints the prompt, and on a tile whose
644 /// pump has died it is also the only thread that can take it off again:
645 /// `paintDeadBarsLocked` redraws the label BAR, one row above this one, so
646 /// an unerased prompt line sits there until the wall comes down.
647 fn tileBannerClear(t: *Tile) void {
648 if (!t.shared.is_tty) return;
649 var buf: [40]u8 = undefined;
650 // Span-bounded ECH, never `\x1b[K`: the cells past this tile's own
651 // width are a rail's and a neighbour's. Saved and restored around it
652 // like `paintBanner`, so no shell's cursor visibly jumps.
653 const text = std.fmt.bufPrint(&buf, "\x1b[s\x1b[{d};{d}H\x1b[0m\x1b[{d}X\x1b[u", .{
654 t.rect.top + t.shared.label_rows + 1,
655 t.rect.left + 1,
656 t.rect.cols,
657 }) catch return;
658 t.shared.paint_mu.lock();
659 defer t.shared.paint_mu.unlock();
660 proto.writeAllFd(t.shared.out_fd, text) catch {};
661 }
662
629 /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the 663 /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the
630 /// paint after it. 664 /// paint after it.
631 fn copySelection( 665 fn copySelection(
@@ -2143,6 +2177,79 @@ fn addSessionTile(
2143 return .{ .moved = at }; 2177 return .{ .moved = at };
2144 } 2178 }
2145 2179
2180 /// A refusal reaches the eyes that earned it: a live tile shows the notice
2181 /// on the re-claim, a dead tile is never claimed, so the keyboard paints it
2182 /// there and TAKES it — left in `shared` it surfaces on a later claim.
2183 fn showRefusal(tiles: []Tile, shared: *Shared, z: usize) void {
2184 if (tiles[z].alive.load(.acquire)) {
2185 setFocus(tiles, shared, z);
2186 return;
2187 }
2188 var buf: [96]u8 = undefined;
2189 const text = takeNotice(shared, &buf);
2190 if (text.len > 0) tileBanner(&tiles[z], text);
2191 }
2192
2193 /// One wording for every spelling the wall will not take, so a typo reads
2194 /// the same whichever of the prompt's two refusals caught it.
2195 fn badTarget(shared: *Shared, e: anyerror) void {
2196 var buf: [96]u8 = undefined;
2197 const text = std.fmt.bufPrint(&buf, "[bad target: {s}]", .{@errorName(e)}) catch "[bad target]";
2198 setNotice(shared, text);
2199 }
2200
2201 /// `Ctrl-\ :`'s answer — argv typed from inside, resolved the way a wall
2202 /// line is and born with `mux TARGET`'s row of the birth table.
2203 fn addSpelledTile(
2204 alloc: std.mem.Allocator,
2205 tiles: []Tile,
2206 present: []bool,
2207 live: *usize,
2208 shared: *Shared,
2209 from: usize,
2210 spelling: []const u8,
2211 key: ?[]const u8,
2212 idle_ms: u32,
2213 ) FocusTo {
2214 // A spelling already on the wall is a focus move, not a second tile.
2215 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| {
2216 if (p and std.mem.eql(u8, t.r.label, spelling)) return .{ .moved = i };
2217 }
2218 // The prompt is argv typed from inside, so it refuses what argv
2219 // refuses: `-A host` is a mistyped flag, not a host, and a wall has no
2220 // per-tile flag it could have meant. Ahead of the dupe, so the answer
2221 // to a typo allocates nothing.
2222 if (wall.flagLike(spelling)) {
2223 badTarget(shared, error.FlagLikeTarget);
2224 return .stay;
2225 }
2226 // The filter's buffer is the next read's; the pump keeps this copy.
2227 const own = alloc.dupe(u8, spelling) catch return .stay;
2228 const r = resolveSpelling(alloc, own, key, idle_ms) catch |err| {
2229 badTarget(shared, err);
2230 // The notice is all a refusal leaves: nothing here touches the wall
2231 // file, because recording is the first state's and a refused tile
2232 // never has one. The keyboard's re-claim is what shows it.
2233 return .stay;
2234 };
2235 if (showsSelf(r.target, r.session, std.posix.getenv(proto.sock_env), std.posix.getenv(proto.session_env))) {
2236 setNotice(shared, "[that is the session this shell is inside]");
2237 return .stay;
2238 }
2239 const at = birthTile(alloc, tiles, present, live, shared, .{
2240 .r = r,
2241 .from = from,
2242 .place = .beside_focus,
2243 // No agent: the user spelled no `-A`, and the host may not be one
2244 // they have exposed a key to.
2245 .creates = true,
2246 .record = true,
2247 .born_from = from,
2248 }) orelse return .full;
2249 spawnPump(&tiles[at]);
2250 return .{ .moved = at };
2251 }
2252
2146 /// How a tile spells itself on the wall: the wall grammar's own form, which 2253 /// How a tile spells itself on the wall: the wall grammar's own form, which
2147 /// is what makes the line `x` removes byte-identical to the line the attach 2254 /// is what makes the line `x` removes byte-identical to the line the attach
2148 /// wrote. 2255 /// wrote.
@@ -2668,6 +2775,9 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2668 // focus — the report bytes themselves go through to the focused tile 2775 // focus — the report bytes themselves go through to the focused tile
2669 // unchanged, so the Core that owns the drag sees them. 2776 // unchanged, so the Core that owns the drag sees them.
2670 var input: WallInput = .{}; 2777 var input: WallInput = .{};
2778 // Whether the last read left the prompt open, so its closing read can
2779 // give the row back to the tile.
2780 var was_prompting = false;
2671 // `feed` hands a candidate held across the previous read back ahead of 2781 // `feed` hands a candidate held across the previous read back ahead of
2672 // this chunk, so its room is a whole chunk plus that hold. 2782 // this chunk, so its room is a whole chunk plus that hold.
2673 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined; 2783 var mouse_out: [mailbox_max + interact.MouseFilter.max_held]u8 = undefined;
@@ -2858,10 +2968,31 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2858 } 2968 }
2859 sendKeys(&tiles[shared.sel], cmd.forward[seg_start..]); 2969 sendKeys(&tiles[shared.sel], cmd.forward[seg_start..]);
2860 } 2970 }
2971 // The prompt is the keyboard's alone — no pump knows it exists —
2972 // so it is painted from here, per read. Leaving it is a re-claim
2973 // of the focus: the claim path repaints the tile over the banner
2974 // and shows any notice a refusal left. `.add_tile` re-claims (or
2975 // moves) in its own arm, so it is the one exit skipped here.
2976 if (input.prefix.prompting) {
2977 var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
2978 const text = std.fmt.bufPrint(&line_buf, ": {s}_", .{input.prefix.promptLine()}) catch "";
2979 if (z < live and present[z]) tileBanner(&tiles[z], text);
2980 was_prompting = true;
2981 } else if (was_prompting) {
2982 was_prompting = false;
2983 if (z < live and present[z]) {
2984 // A dead tile answers no doorbell, so no claim will ever
2985 // repaint over the line the keyboard wrote: it erases its
2986 // own. However the prompt ended — Enter, Esc or Ctrl-C —
2987 // the line on that row is the keyboard's to take back.
2988 if (!tiles[z].alive.load(.acquire))
2989 tileBannerClear(&tiles[z])
2990 else if (cmd.action != .add_tile)
2991 setFocus(tiles[0..live], &shared, z);
2992 }
2993 }
2861 switch (cmd.action) { 2994 switch (cmd.action) {
2862 .none => {}, 2995 .none => {},
2863 // The spelling has no consumer in the wall yet.
2864 .add_tile => {},
2865 .detach => { 2996 .detach => {
2866 // The slot goes back to the daemon before this process 2997 // The slot goes back to the daemon before this process
2867 // does — the pump writes the frame, because a Transport 2998 // does — the pump writes the frame, because a Transport
@@ -2953,6 +3084,22 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved, entry: Entry) !
2953 if (z < live and present[z]) 3084 if (z < live and present[z])
2954 forgetTile(alloc, tiles[0..live], present[0..live], &shared, z, &forget_err); 3085 forgetTile(alloc, tiles[0..live], present[0..live], &shared, z, &forget_err);
2955 }, 3086 },
3087 .add_tile => |spelling| {
3088 const before = live;
3089 switch (addSpelledTile(alloc, tiles, present, &live, &shared, z, spelling, entry.key, entry.idle_ms)) {
3090 .moved => |to| {
3091 last_focus = z;
3092 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, live > before, to);
3093 },
3094 .full => {
3095 setNotice(&shared, "[no room on the wall for another tile]");
3096 showRefusal(tiles[0..live], &shared, z);
3097 },
3098 // The refusal left its notice; `showRefusal` is what
3099 // puts it in front of the eyes that earned it.
3100 .stay => showRefusal(tiles[0..live], &shared, z),
3101 }
3102 },
2956 } 3103 }
2957 } 3104 }
2958 3105
@@ -3106,6 +3253,34 @@ test "birthTile: a prompt-born tile creates, records, and offers no agent" {
3106 try std.testing.expectEqual(@as(u16, 1), shared.label_rows); 3253 try std.testing.expectEqual(@as(u16, 1), shared.label_rows);
3107 } 3254 }
3108 3255
3256 test "addSpelledTile: a flag-like spelling is refused, as argv refuses it" {
3257 const alloc = std.testing.allocator;
3258 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
3259 defer shared.tree.deinit();
3260 try shared.tree.addFirst(0);
3261 var tiles: [2]Tile = undefined;
3262 tiles[0] = .{
3263 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
3264 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
3265 .shared = &shared,
3266 .idx = 0,
3267 .wake_r = -1,
3268 .wake_w = -1,
3269 };
3270 var present = [_]bool{ true, false };
3271 var live: usize = 1;
3272 // The typo `mux wall -A host` dies at, typed at the prompt instead: a
3273 // tile for a host named `-A` is nobody's intent at either mouth. The
3274 // host part is unresolvable on purpose — a refusal that reached a dial
3275 // would be this assertion passing for the wrong reason.
3276 const to = addSpelledTile(alloc, &tiles, &present, &live, &shared, 0, "-A nosuchhost.invalid", null, 30_000);
3277 try std.testing.expect(to == .stay);
3278 try std.testing.expectEqual(@as(usize, 1), live);
3279 try std.testing.expect(!present[1]);
3280 var buf: [96]u8 = undefined;
3281 try std.testing.expectEqualStrings("[bad target: FlagLikeTarget]", takeNotice(&shared, &buf));
3282 }
3283
3109 test "birthTile: a fold-born tile joins, does not record, offers no agent" { 3284 test "birthTile: a fold-born tile joins, does not record, offers no agent" {
3110 const alloc = std.testing.allocator; 3285 const alloc = std.testing.allocator;
3111 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false }; 3286 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
@@ -4734,3 +4909,65 @@ test "restore: focus_out maps the saved focus through the spelling match" {
4734 try std.testing.expectEqual(@as(?u8, null), focus_out); 4909 try std.testing.expectEqual(@as(?u8, null), focus_out);
4735 } 4910 }
4736 } 4911 }
4912
4913 test "tileBannerClear: the prompt's row is erased inside the tile, never past it" {
4914 // A tile whose pump has died gets no claim to repaint over a banner,
4915 // so the keyboard takes its own prompt line back off the screen. The
4916 // erase is a span bounded by the tile's rect: line-wide would take the
4917 // rail and the neighbour's cells with it.
4918 const pipe = try std.posix.pipe();
4919 defer std.posix.close(pipe[0]);
4920 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4921 shared.label_rows = 1;
4922 var t = Tile{
4923 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4924 .rect = .{ .top = 2, .left = 5, .rows = 5, .cols = 10 },
4925 .shared = &shared,
4926 .idx = 0,
4927 .wake_r = -1,
4928 .wake_w = -1,
4929 };
4930 tileBannerClear(&t);
4931 std.posix.close(pipe[1]);
4932
4933 var out: [256]u8 = undefined;
4934 const n = try std.posix.read(pipe[0], &out);
4935 const got = out[0..n];
4936 // The same corner `tileBanner` writes into, and exactly as many cells
4937 // wide as the tile.
4938 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[4;6H") != null);
4939 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[10X") != null);
4940 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[K") == null);
4941 }
4942
4943 test "tileBanner: a prompt wider than its pane shows the tail inside the pane" {
4944 // A tile that painted a whole long spelling would run across the rail
4945 // into its neighbour, so the banner is bounded by the tile's rect and
4946 // not by the terminal. The tail is what is kept: the cursor end of the
4947 // line the user is still typing.
4948 const pipe = try std.posix.pipe();
4949 defer std.posix.close(pipe[0]);
4950 var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
4951 shared.label_rows = 1;
4952 var t = Tile{
4953 .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "" },
4954 .rect = .{ .top = 2, .left = 5, .rows = 5, .cols = 10 },
4955 .shared = &shared,
4956 .idx = 0,
4957 .wake_r = -1,
4958 .wake_w = -1,
4959 };
4960 const text = ": --sock /tmp/very/long/paths_";
4961 try std.testing.expectEqual(@as(usize, 30), text.len);
4962 tileBanner(&t, text);
4963 std.posix.close(pipe[1]);
4964
4965 var out: [256]u8 = undefined;
4966 const n = try std.posix.read(pipe[0], &out);
4967 const got = out[0..n];
4968 // The tile's own corner: rect.top + label_rows + 1, rect.left + 1.
4969 try std.testing.expect(std.mem.indexOf(u8, got, "\x1b[4;6H") != null);
4970 const from = std.mem.indexOf(u8, got, "\x1b[7m").? + 4;
4971 const to = std.mem.indexOf(u8, got, "\x1b[0m").?;
4972 try std.testing.expectEqualStrings(text[text.len - 10 ..], got[from..to]);
4973 }
test/e2e.sh
Old New
@@ -378,6 +378,8 @@ SOCK58="${TMPDIR:-/tmp}/muxd-e2e-splitbirth-$$.sock"
378 SOCK59="${TMPDIR:-/tmp}/muxd-e2e-lprrestore-$$.sock" 378 SOCK59="${TMPDIR:-/tmp}/muxd-e2e-lprrestore-$$.sock"
379 SOCK60="${TMPDIR:-/tmp}/muxd-e2e-lpheal-$$.sock" 379 SOCK60="${TMPDIR:-/tmp}/muxd-e2e-lpheal-$$.sock"
380 SOCK61="${TMPDIR:-/tmp}/muxd-e2e-lpdegrade-$$.sock" 380 SOCK61="${TMPDIR:-/tmp}/muxd-e2e-lpdegrade-$$.sock"
381 SOCK62="${TMPDIR:-/tmp}/muxd-e2e-promptA-$$.sock"
382 SOCK63="${TMPDIR:-/tmp}/muxd-e2e-promptB-$$.sock"
381 D54PID="" 383 D54PID=""
382 D55PID="" 384 D55PID=""
383 D56PID="" 385 D56PID=""
@@ -387,6 +389,8 @@ D59PID=""
387 D60PID="" 389 D60PID=""
388 D61PID="" 390 D61PID=""
389 D62PID="" 391 D62PID=""
392 D63PID=""
393 D64PID=""
390 394
391 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its 395 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its
392 # own, for the dynamic-wall leg's reason turned up one notch: what these 396 # own, for the dynamic-wall leg's reason turned up one notch: what these
@@ -1329,6 +1333,8 @@ cleanup() {
1329 [ -n "${D60PID:-}" ] && kill "$D60PID" 2>/dev/null || true 1333 [ -n "${D60PID:-}" ] && kill "$D60PID" 2>/dev/null || true
1330 [ -n "${D61PID:-}" ] && kill "$D61PID" 2>/dev/null || true 1334 [ -n "${D61PID:-}" ] && kill "$D61PID" 2>/dev/null || true
1331 [ -n "${D62PID:-}" ] && kill "$D62PID" 2>/dev/null || true 1335 [ -n "${D62PID:-}" ] && kill "$D62PID" 2>/dev/null || true
1336 [ -n "${D63PID:-}" ] && kill "$D63PID" 2>/dev/null || true
1337 [ -n "${D64PID:-}" ] && kill "$D64PID" 2>/dev/null || true
1332 # The ssh-agents the forwarding legs start. Not mux processes and so not 1338 # The ssh-agents the forwarding legs start. Not mux processes and so not
1333 # the leak sweep's business, but they are daemons this file forked: left 1339 # the leak sweep's business, but they are daemons this file forked: left
1334 # alive they outlive the suite holding a private key, which is the one 1340 # alive they outlive the suite holding a private key, which is the one
@@ -1397,7 +1403,7 @@ cleanup() {
1397 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \ 1403 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID" \
1398 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \ 1404 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \
1399 "$D55PID" "$D56PID" "$D57PID" "$D58PID" "$D59PID" \ 1405 "$D55PID" "$D56PID" "$D57PID" "$D58PID" "$D59PID" \
1400 "$D60PID" "$D61PID" "$D62PID" 1406 "$D60PID" "$D61PID" "$D62PID" "$D63PID" "$D64PID"
1401 _leak=0 1407 _leak=0
1402 leak_sweep "$_rc" || _leak=1 1408 leak_sweep "$_rc" || _leak=1
1403 1409
@@ -1490,7 +1496,10 @@ cleanup() {
1490 "$OUT.lphfa" "$OUT.lphstop" "$SOCK60" \ 1496 "$OUT.lphfa" "$OUT.lphstop" "$SOCK60" \
1491 "$OUT.lpd.d" "$OUT.lpda" "$OUT.lpda.err" \ 1497 "$OUT.lpd.d" "$OUT.lpda" "$OUT.lpda.err" \
1492 "$OUT.lpdcap" "$OUT.lpdcap.err" "$OUT.lpdpc" \ 1498 "$OUT.lpdcap" "$OUT.lpdcap.err" "$OUT.lpdpc" \
1493 "$OUT.lpdfa" "$OUT.lpdstop" "$SOCK61" 1499 "$OUT.lpdfa" "$OUT.lpdstop" "$SOCK61" \
1500 "$OUT.pra.d" "$OUT.prb.d" "$OUT.pra" "$OUT.pra.err" "$OUT.prcap" "$OUT.prcap.err" \
1501 "$OUT.prgrid" \
1502 "$OUT.prpc" "$OUT.prfb" "$OUT.prastop" "$OUT.prbstop" "$SOCK62" "$SOCK63"
1494 # ...and the non-tty capture that leg's session feeds. 1503 # ...and the non-tty capture that leg's session feeds.
1495 rm -f "$OUT.nogate" 1504 rm -f "$OUT.nogate"
1496 # ...and its other half: the paste capture and the file nvim wrote, which 1505 # ...and its other half: the paste capture and the file nvim wrote, which
@@ -1630,7 +1639,7 @@ cleanup() {
1630 "$OUT.saself" "$OUT.sast" "$OUT.sastop" "$OUT.sawall" 1639 "$OUT.saself" "$OUT.sast" "$OUT.sastop" "$OUT.sawall"
1631 # the dead-tile leg. 1640 # the dead-tile leg.
1632 rm -f "$OUT.zda" "$OUT.zda.err" "$OUT.zdcap" "$OUT.zdcap.err" \ 1641 rm -f "$OUT.zda" "$OUT.zda.err" "$OUT.zdcap" "$OUT.zdcap.err" \
1633 "$OUT.zd.d" "$OUT.zdpc" "$OUT.zdstop" "$OUT.zdcapa" 1642 "$OUT.zd.d" "$OUT.zdpc" "$OUT.zdstop" "$OUT.zdcapa" "$OUT.zdgrid"
1634 # focus skip, and its watchers. 1643 # focus skip, and its watchers.
1635 rm -f "$OUT.zsa" "$OUT.zsa.err" "$OUT.zsb" "$OUT.zsb.err" "$OUT.zscap" \ 1644 rm -f "$OUT.zsa" "$OUT.zsa.err" "$OUT.zsb" "$OUT.zsb.err" "$OUT.zscap" \
1636 "$OUT.zscap.err" "$OUT.zs.d" "$OUT.zsfa" "$OUT.zsfb" "$OUT.zspc" \ 1645 "$OUT.zscap.err" "$OUT.zs.d" "$OUT.zsfa" "$OUT.zsfb" "$OUT.zspc" \
@@ -6021,6 +6030,19 @@ settle 800 20000
6021 send printf 'zdagain-%s\\n' pin\n 6030 send printf 'zdagain-%s\\n' pin\n
6022 expect zdagain-pin 15000 6031 expect zdagain-pin 15000
6023 settle 400 15000 6032 settle 400 15000
6033 send \x1c2
6034 settle 400 15000
6035 send \x1c:abc
6036 settle 400 15000
6037 send \x1b
6038 settle 400 15000
6039 send \x1c:zz#bad name
6040 settle 400 15000
6041 send \r
6042 expect [bad target 10000
6043 settle 600 15000
6044 send \x1c1
6045 settle 400 15000
6024 send \x1cd 6046 send \x1cd
6025 waitexit 10000 6047 waitexit 10000
6026 EOF 6048 EOF
@@ -6044,9 +6066,30 @@ timeout 20 "$MUXA" capture --sock "$SOCK39" --session a > "$OUT.zdcapa" 2>&1
6044 grep -q "zdagain-pin" "$OUT.zdcapa" || { 6066 grep -q "zdagain-pin" "$OUT.zdcapa" || {
6045 echo "e2e FAIL: dead tile: the live session never got the focused marker:" 6067 echo "e2e FAIL: dead tile: the live session never got the focused marker:"
6046 cat "$OUT.zdcapa"; exit 1; } 6068 cat "$OUT.zdcapa"; exit 1; }
6069 # `Ctrl-\ :` is the one chord that works with the focus on a dead tile —
6070 # the splits go through the pump's `ask`, and a dead pump answers nothing —
6071 # so it is the one chord that can leave a line on a row nothing repaints.
6072 # The oracle, not the byte stream: a banner LEFT on the screen is in the
6073 # capture either way, and only the render says what the human was still
6074 # looking at.
6075 "$RENDER" --cols 70 --rows 36 < "$OUT.zdcap" > "$OUT.zdgrid" || {
6076 echo "e2e FAIL: dead tile: render oracle failed"
6077 cat "$OUT.zdgrid"; exit 1; }
6078 # The refusal reached the eyes that earned it. Nothing claims a dead tile,
6079 # so a notice only the claim path can show would never have been seen.
6080 grep -q "\[bad target" "$OUT.zdgrid" || {
6081 echo "e2e FAIL: dead tile: a refused prompt never narrated on the dead tile:"
6082 cat "$OUT.zdgrid"; exit 1; }
6083 grep -q ": zz#bad" "$OUT.zdgrid" && {
6084 echo "e2e FAIL: dead tile: the refused prompt is still on the dead tile's row:"
6085 cat "$OUT.zdgrid"; exit 1; }
6086 # And Esc leaves nothing behind either: same row, same missing claim.
6087 grep -q ": abc" "$OUT.zdgrid" && {
6088 echo "e2e FAIL: dead tile: the cancelled prompt is still on the dead tile's row:"
6089 cat "$OUT.zdgrid"; exit 1; }
6047 assert_stopped "$SOCK39" "$D36PID" "dead tile" "$OUT.zdstop" 6090 assert_stopped "$SOCK39" "$D36PID" "dead tile" "$OUT.zdstop"
6048 D36PID="" 6091 D36PID=""
6049 ok "a tile whose pump has died narrates on its bar, and the focus stays on the live one" 6092 ok "a tile whose pump has died narrates on its bar and on its prompt, and the focus stays on the live one"
6050 6093
6051 # ---- the wall is attach HISTORY ----------------------------------------- 6094 # ---- the wall is attach HISTORY -----------------------------------------
6052 # 6095 #
@@ -8351,8 +8394,124 @@ D62PID=""
8351 rm -rf "$LPDSTATE" 8394 rm -rf "$LPDSTATE"
8352 ok "a corrupted sidecar degrades silently to the default layout" 8395 ok "a corrupted sidecar degrades silently to the default layout"
8353 8396
8354 [ "$OK_COUNT" = "72" ] || { 8397 # ---- Ctrl-\ : adds a tile by spelling ---------------------------------
8355 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 72 —" 8398 #
8399 # Argv typed from inside. A spelling naming a session on ANOTHER daemon
8400 # is born beside the focus, creates that session there, takes the focus,
8401 # and is recorded into the wall file — mux TARGET's row of the birth
8402 # table, without leaving the wall. A bad spelling is a notice and
8403 # nothing else (the file does not grow). Esc eats the line: the shell
8404 # never sees it, and the next keys reach the session again.
8405 #
8406 # The prompt echoes what is typed, so the born tile's witness is not its
8407 # label on the capture (the echo would match) but a marker typed AFTER
8408 # the birth landing in daemon B's session b — a hit is B's shell's work.
8409 PRSTATE="${TMPDIR:-/tmp}/mux-e2e-prompt-state-$$"
8410 PRWALL="$PRSTATE/mux/wall"
8411 "$MUXD" run --sock "$SOCK62" --shell /bin/sh > "$OUT.pra.d" 2>&1 &
8412 D63PID=$!
8413 wait_sock "$SOCK62" "$OUT.pra.d" "prompt daemon A never bound"
8414 "$MUXD" run --sock "$SOCK63" --shell /bin/sh > "$OUT.prb.d" 2>&1 &
8415 D64PID=$!
8416 wait_sock "$SOCK63" "$OUT.prb.d" "prompt daemon B never bound"
8417
8418 pipe_mux "$OUT.pra" "$OUT.pra.err" timeout 40 "$MUX" --sock "$SOCK62" --session a
8419 pipe_send 'printf "pr-%%s\\n" origin\n'
8420 await_out "$OUT.pra" "pr-origin" "prompt: session a marker never reached the client"
8421 pipe_detach
8422 wait_grid "$SOCK62" "pr-origin" "prompt: session a marker" a
8423
8424 mkdir -p "$PRSTATE/mux"
8425 printf -- '--sock %s#a\n' "$SOCK62" > "$PRWALL"
8426
8427 # The spelling goes in TWO sends with a settle between: the prompt paints
8428 # its echo when a read ENDS with the prompt still open, so a spelling that
8429 # arrives in the same read as its own \r paints nothing to assert on. The
8430 # separating space is \x20 because parseLine trims a payload's trailing one.
8431 set +e
8432 XDG_STATE_HOME="$PRSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
8433 --out "$OUT.prcap" --err "$OUT.prcap.err" -- \
8434 "$MUX" wall > "$OUT.prpc" 2>&1 <<EOF
8435 expect pr-origin 20000
8436 settle 700 20000
8437 send \x1c:--sock\x20
8438 settle 300 5000
8439 send $SOCK63#b\r
8440 settle 1000 20000
8441 send printf 'pr-born-%s\n' marker\n
8442 expect pr-born-marker 15000
8443 send \x1c:x#bad name\r
8444 expect [bad target 10000
8445 settle 500 15000
8446 send \x1c:-A nosuchhost.invalid\r
8447 expect FlagLikeTarget 10000
8448 settle 500 15000
8449 send \x1c:zzz\x1b
8450 settle 500 15000
8451 send printf 'pr-after-%s\n' esc\n
8452 expect pr-after-esc 10000
8453 settle 500 15000
8454 send \x1cd
8455 waitexit 10000
8456 EOF
8457 RC=$?
8458 set -e
8459 [ "$RC" -eq 0 ] || {
8460 echo "e2e FAIL: prompt: ptyclient leg exited $RC (did \\x1c: add the tile?):"
8461 cat "$OUT.prpc" "$OUT.prcap.err"; exit 1; }
8462 # The oracle, not the byte stream: a banner LEFT on the screen is invisible
8463 # to a grep of the emitted bytes — the `: zzz` that painted is in the
8464 # capture either way. Only the render's final grid says what the human was
8465 # still looking at when the wall came down.
8466 "$RENDER" --cols 100 --rows 30 < "$OUT.prcap" > "$OUT.prgrid" || {
8467 echo "e2e FAIL: prompt: render oracle failed"
8468 cat "$OUT.prgrid"; exit 1; }
8469 grep -q ": zzz" "$OUT.prgrid" && {
8470 echo "e2e FAIL: prompt: the Esc'd prompt is still on the screen:"
8471 cat "$OUT.prgrid"; exit 1; }
8472 grep -q "pr-after-esc" "$OUT.prgrid" || {
8473 echo "e2e FAIL: prompt: the tile's content did not come back after Esc:"
8474 cat "$OUT.prgrid"; exit 1; }
8475 # argv's refusal, at argv's other mouth: `-A host` is a mistyped flag and
8476 # never becomes a tile for a host named `-A`. The bar is the witness — a
8477 # born tile paints its spelling and a state word beside it, and the host
8478 # here resolves nowhere, so the tile would sit there saying [refused].
8479 grep -q -- "-A nosuchhost.invalid \[" "$OUT.prgrid" && {
8480 echo "e2e FAIL: prompt: a flag-like spelling became a tile:"
8481 cat "$OUT.prgrid"; exit 1; }
8482 # The prompt is a line the typist can read back: -a because the capture is
8483 # a terminal stream, escape bytes and all.
8484 grep -aq ": --sock " "$OUT.prcap" || {
8485 echo "e2e FAIL: prompt: the prompt line never painted its echo:"
8486 cat "$OUT.prpc"; exit 1; }
8487 # Born on daemon B, focused: the marker typed after the birth is in B's
8488 # session b, and so are the keys typed after the Esc.
8489 timeout 20 "$MUXA" capture --sock "$SOCK63" --session b > "$OUT.prfb" 2>&1
8490 grep -q "pr-born-marker" "$OUT.prfb" || {
8491 echo "e2e FAIL: prompt: marker not in daemon B's session b (tile not born there, or not focused):"
8492 cat "$OUT.prfb"; exit 1; }
8493 grep -q "pr-after-esc" "$OUT.prfb" || {
8494 echo "e2e FAIL: prompt: keys after Esc never reached session b (prompt did not close):"
8495 cat "$OUT.prfb"; exit 1; }
8496 grep -q "zzz" "$OUT.prfb" && {
8497 echo "e2e FAIL: prompt: Esc leaked the line into the shell:"
8498 cat "$OUT.prfb"; exit 1; }
8499 # Recorded, and only the accepted spelling: two lines, the born one among them.
8500 grep -qF -- "--sock $SOCK63#b" "$PRWALL" || {
8501 echo "e2e FAIL: prompt: the born tile was not recorded:"
8502 cat "$PRWALL"; exit 1; }
8503 [ "$(grep -c . "$PRWALL")" = "2" ] || {
8504 echo "e2e FAIL: prompt: wall file is not exactly two lines (a refused spelling recorded?):"
8505 cat "$PRWALL"; exit 1; }
8506 assert_stopped "$SOCK62" "$D63PID" "prompt A" "$OUT.prastop"
8507 D63PID=""
8508 assert_stopped "$SOCK63" "$D64PID" "prompt B" "$OUT.prbstop"
8509 D64PID=""
8510 rm -rf "$PRSTATE"
8511 ok "Ctrl-\\ : adds a tile by spelling: born on another daemon, recorded, refusals narrated, Esc eats the line"
8512
8513 [ "$OK_COUNT" = "73" ] || {
8514 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 73 —"
8356 echo " a scenario was added (update the pin) or silently lost" 8515 echo " a scenario was added (update the pin) or silently lost"
8357 exit 1 8516 exit 1
8358 } 8517 }