c48a660c
feat: Ctrl-\ : adds a tile by spelling from inside the wall
a73x 2026-08-25 19:32
Commit message
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 | } |