Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6bf32e6
fix(bar): a painted width one short of the target is at the target
olliegilbey Sep 22, 2026
0a7b5f3
docs(status): the nav regression on the box and the QA requirement
olliegilbey Sep 22, 2026
9a6f661
docs(status): nav skips the dead baked tab on the box
olliegilbey Sep 22, 2026
594fc59
docs(status): handoff for the devbox regressions after the 0.5.2 cut
olliegilbey Sep 22, 2026
df93f15
docs(types): put the target_cols doc comment back on target_cols
olliegilbey Sep 22, 2026
f2aeaca
fix(bar): the card renderer tolerates the separator column too
olliegilbey Sep 22, 2026
2514ab3
docs(status): record the review round on the separator-column fix
olliegilbey Sep 22, 2026
fe5a3fd
feat(qa): the drive runs on a remote machine over ssh, and asserts th…
olliegilbey Sep 22, 2026
e6dc84a
fix(qa): the remote drive runs detached on the remote, read back by q…
olliegilbey Sep 22, 2026
b8f1ba4
fix(qa): ct.sh finds zellij's sockets under XDG_RUNTIME_DIR on Linux
olliegilbey Sep 22, 2026
25af2e7
docs(qa): run 23, the first remote drive, green on the devbox
olliegilbey Sep 22, 2026
3db9e55
docs(status): the post-drive Alt+c on the box is a swap that does not…
olliegilbey Sep 22, 2026
ac04ea7
docs(status): the Alt+c retry on the box, same three unlanded asks, f…
olliegilbey Sep 22, 2026
7f3c8e3
docs(status): handoff after run 23 and the refused swap on the box
olliegilbey Sep 22, 2026
4b7e8cb
feat(bar): the swap-width log line names own tab, focused tab and pan…
olliegilbey Sep 22, 2026
7ad5f1d
feat(bar): the load and swap-width lines name the plugin's client id
olliegilbey Sep 22, 2026
9eb0c3f
feat(bar): log the gate that holds a width ask, and two sandbox width…
olliegilbey Sep 22, 2026
b78cb37
feat(bar): log every paint width change and the cooldown's own asks
olliegilbey Sep 22, 2026
6034bfa
docs(status): the restore leaves the beacon on the last opened tab, m…
olliegilbey Sep 22, 2026
3338bb2
fix(bar): derive the restore re-anchor from the beacon, not a claim
olliegilbey Sep 22, 2026
2777430
test(qa): phase 6c presses the toggle in the restored fleet's focused…
olliegilbey Sep 22, 2026
ebcdf10
fix(bar): bound the restore re-anchor to the restore, drained on emit
olliegilbey Sep 22, 2026
8e27f47
docs(qa): record the twice-failed latch, pin 6c's id sets, settle the…
olliegilbey Sep 22, 2026
6cbc67e
fix(bar): arm the restore re-anchor at the beacon that steals it
olliegilbey Sep 22, 2026
b08bf7f
fix(qa): the remote stage runs attached, and the launch line follows it
olliegilbey Sep 22, 2026
ef6fffb
docs(status): run 28 red in phase 2, the register write did not land;…
olliegilbey Sep 22, 2026
02f30ae
fix(add): record the row before the tab is created, so the spawn's re…
olliegilbey Sep 22, 2026
4124b9b
docs(qa): ledger runs 26 to 29, and the footgun for the tab born befo…
olliegilbey Sep 22, 2026
9670fbf
feat!: remove the live-set restore; a relaunch bakes one eager tab an…
olliegilbey Sep 22, 2026
957850f
feat(qa): phase 6c verifies one eager tab and a dormant fleet after a…
olliegilbey Sep 22, 2026
0c59245
docs(restore): record the removal; fold the restore footguns into one…
olliegilbey Sep 22, 2026
f14d40b
fix(qa): phase 5b expects idle after SessionEnd again
olliegilbey Sep 22, 2026
bb00812
docs(qa): ledger runs 31 and 32, the stripped build green 0-7 on box …
olliegilbey Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ just setup-toolchain # adds the wasm32-wasip1 target
just sandbox # builds, wires an isolated sandbox, verifies it
```

The same loop runs against a second machine over ssh — `just remote-qa`,
`just remote-log` — for the regressions only that machine's environment
shows (frames off, a different Claude Code). See
[docs/dev/QA-DRIVE.md](docs/dev/QA-DRIVE.md), "The remote drive".

Then launch it yourself, **in a new terminal, outside zellij** — clave creates
its own multiplexer session, so launching from inside one nests them:

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Zellij's keys still belong to Zellij.
- Each tab is a terminal. As usual. But with extra info shown in the tab text.
- If the terminal is an agent TUI (like Claude Code), the sidebar is populated with rich information about the agent state.
- Sidebar state comes from either [Claude Code hooks](https://code.claude.com/docs/en/hooks), or from your `.claude` `jsonl` store that Claude Code already keeps.
- **Your agents survive restarts.** Relaunch and every agent tab you had open comes back, in the order you had them. The top one picks up straight away, the rest pick up when you arrive at them. Plain terminal tabs do not come back. Tabs you had CLOSED stay closed as dormant rows: open one with `Alt+Enter` and it resumes where it left off.
- **Your agents survive restarts.** A relaunch opens one tab for the most recent conversation. Every other conversation is a dormant row; `Alt+Enter` opens it, and it resumes where it left off. Plain terminal tabs do not come back.
- **Running tabs sit above closed ones**, so the agents and terminals you're using are quick to cycle through (with `Alt+↑` `Alt+↓`).
- **The tab list orders itself by attention.** A modified "frecency" algorithm is used to keep the tabs you're most likely to reuse at the top.

Expand Down
86 changes: 84 additions & 2 deletions crates/clave-bar/src/card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ const EXPANDED_COLS: usize = clave_types::RowHeight::Double.target_cols(false);
const CARD_COLLAPSED_COLS: usize = clave_types::RowHeight::Card.target_cols(true);
const CARD_EXPANDED_COLS: usize = clave_types::RowHeight::Card.target_cols(false);

/// Whether a painted width gets the expanded profile. A frameless pane
/// paints one column short of its declared width (FOOTGUNS: "A frameless
/// pane paints one column short"), so the threshold tolerates exactly the
/// separator column, the same allowance the model's `RowHeight::mode_at`
/// makes. Exact here and tolerant there would leave a frames-off bar parked
/// at the expanded width drawing the collapsed card: a third state.
fn is_expanded(build: usize, expanded_cols: usize) -> bool {
build + clave_types::SEPARATOR_COLS >= expanded_cols
}

/// The card's left chrome: margin, mark, air, rail, air. A four-cell version
/// that butted the rail against the mark column was rejected on sight — the
/// rule sat on top of the glyphs. The fifth column costs nothing that matters:
Expand Down Expand Up @@ -332,7 +342,7 @@ pub(crate) fn render_double_card(
// and loses the same trailing cells on every row (LEDGER D13), rather than
// going ragged or — worse — wrapping into a third line.
let build = cols.max(COLLAPSED_COLS);
let branch_w = if build >= EXPANDED_COLS {
let branch_w = if is_expanded(build, EXPANDED_COLS) {
BRANCH_MIN
} else {
0
Expand Down Expand Up @@ -511,7 +521,7 @@ pub(crate) fn render_card(
// and loses the same trailing cells on every row rather than going ragged
// or, worse, wrapping into a fifth line.
let build = cols.max(CARD_COLLAPSED_COLS);
let expanded = build >= CARD_EXPANDED_COLS;
let expanded = is_expanded(build, CARD_EXPANDED_COLS);
let c = cells(&row.content, theme);

let dormant = row.dormant
Expand Down Expand Up @@ -1492,6 +1502,78 @@ mod tests {
}
}

/// A frameless pane paints one column short of its declared width
/// (FOOTGUNS: "A frameless pane paints one column short"). The expanded
/// card must still be the expanded card at 47: the PR cell and the model
/// tail stay, and the rows fill exactly the painted width. Before the
/// fix the threshold was exact, so a frames-off bar sat at full width
/// drawing the collapsed card, which reads as a third state.
#[test]
fn the_card_one_column_under_expanded_is_the_expanded_card() {
let row = A {
tokens: Some(9_949_999),
pr: Some(1234),
branch: "feat/some-long-branch-name",
repo: "a-long-repository-name",
elapsed: "59s",
..A::default()
}
.row();
let painted = CARD_EXPANDED_COLS - clave_types::SEPARATOR_COLS;
let got = render_card(&row, painted, false, 0, &Theme::default());
let l2 = strip_sgr(&got[1]);
let l3 = strip_sgr(&got[2]);
assert!(
l2.contains("1234") || l3.contains("1234"),
"the PR cell must survive one column short: {l2:?} / {l3:?}"
);
assert!(l2.contains("fable"), "the model tail must survive: {l2:?}");
for (i, line) in got.iter().enumerate() {
assert_eq!(
display_cells(&strip_sgr(line)),
painted,
"line {} left the painted width",
i + 1
);
}
// Two short is still the collapsed card: the tolerance is exactly
// the separator column, not a slope.
let two_short = render_card(&row, painted - 1, false, 0, &Theme::default());
let l2 = strip_sgr(&two_short[1]);
let l3 = strip_sgr(&two_short[2]);
assert!(
!l2.contains("1234") && !l3.contains("1234"),
"two short must draw the collapsed card: {l2:?} / {l3:?}"
);
}

/// The same separator column, on the double card: its branch cell is the
/// thing the expanded width buys, and it must not vanish at 47.
#[test]
fn the_double_card_one_column_under_expanded_keeps_its_branch() {
// With a PR the collapsed profile has no columns left for a branch,
// so its presence is the one-bit read of which profile was drawn.
let row = A {
branch: "feat/some-long-branch-name",
pr: Some(1234),
..A::default()
}
.row();
let painted = EXPANDED_COLS - clave_types::SEPARATOR_COLS;
let (l1, l2) = render_double_card(&row, painted, false, false, &Theme::default());
let joined = format!("{}{}", strip_sgr(&l1), strip_sgr(&l2));
assert!(
joined.contains("feat/"),
"the branch cell must survive one column short: {joined:?}"
);
let (l1, l2) = render_double_card(&row, painted - 1, false, false, &Theme::default());
let joined = format!("{}{}", strip_sgr(&l1), strip_sgr(&l2));
assert!(
!joined.contains("feat/"),
"two short must draw the collapsed double card: {joined:?}"
);
}

/// The cell column a needle starts at. Line 3's chrome carries a
/// multi-byte mark and rule, so a byte offset is not a column.
fn cell_of(line: &str, needle: &str) -> Option<usize> {
Expand Down
104 changes: 69 additions & 35 deletions crates/clave-bar/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ struct State {
/// the click map falls back to the pre-viewport identity mapping (line N
/// selects row N) rather than misbehaving.
pane_height: usize,
/// The last `width-deaf` line written, so a bar held at the wrong width
/// logs once per (width, reason) and not once per paint.
last_deaf: Option<(usize, &'static str)>,
/// The width of the last paint, so `render` logs a `painted` line only
/// when zellij changes it — the trace of a swap landing, or being undone.
last_cols: Option<usize>,
/// A term-facts poll timer is in flight (#206) — one at a time, re-armed
/// on expiry only while `term_poll_wanted()` holds.
term_poll_armed: bool,
Expand Down Expand Up @@ -247,12 +253,6 @@ impl State {
let bin = self.clave_binary.clone();
for e in effects {
match e {
Effect::RunHeldPane { pane_id } => {
// The restored tab's agent starts here — `rerun` is
// zellij's one verb for a held command pane, and a pane
// held from birth has simply never run once.
rerun_command_pane(pane_id);
}
Effect::FocusPane { pane_id } => {
// S2-proven nav: focus the terminal pane; Zellij pulls
// its tab forward. go_to_tab is a known dead end.
Expand Down Expand Up @@ -464,7 +464,7 @@ impl State {
),
);
}
Effect::OpenAgent { uuid, restore_to } => {
Effect::OpenAgent { uuid } => {
// Collapse mode rides along for D36's reason: the new tab
// must be born in the mode the fleet is in. The width
// needs no measuring — the layout `clave open` writes is
Expand All @@ -473,13 +473,6 @@ impl State {
if self.model.collapsed {
argv.push("--collapsed");
}
// #261: held, and hand the focus back to this tab. The
// model decided both — see `Effect::OpenAgent`.
let home = restore_to.map(|t| t.to_string());
if let Some(home) = home.as_deref() {
argv.push("--restore-to");
argv.push(home);
}
run_command(&argv, BTreeMap::new());
}
Effect::PersistCollapse { collapsed } if presumed => {
Expand Down Expand Up @@ -521,15 +514,6 @@ impl State {
// out of `identity_effects` also keeps that function's contract what it
// has always been — the actions to take, nothing else.
let mut fx: Vec<Effect> = self.model.bind_stall_report().into_iter().collect();
// The held-tab binds. Kept beside `identity_effects` rather than
// inside it so the two ledgers stay visibly separate — `bind_effects`
// clears `bind_sent` for every uuid without a registered pane, which
// is this leg's whole population (CodeRabbit, #261). It carries its
// OWN election gate and reports for every held tab, not just ours:
// zellij sends the tab frame only to the focused tab, so a bar in an
// unvisited tab cannot resolve its own tab id at all. The elected bar
// can, for all of them — the pane manifest is global.
fx.extend(self.model.restored_bind_effects());
fx.extend(self.model.identity_effects());
if !fx.is_empty() {
self.run_effects(fx);
Expand Down Expand Up @@ -707,15 +691,6 @@ impl State {
self.pending_peeks += 1;
set_timeout(PEEK_SINK_SECS); // user-tuned: 1.0 felt a touch long
}
// The beacon is a join input, like the two frames (#261).
// Waking a held agent needs the beacon AND this instance's
// own tab, and the two arrive by different routes: on a nav
// landing the target bar gets its `TabUpdate` while the
// beacon still names the tab the human left, so the wake
// arm refuses — and without this line nothing re-enters
// when the beacon catches up. Fail-closed and idempotent,
// so settling on the losing order costs nothing.
self.settle_identity();
true // active-row highlight may move
}
Err(e) => {
Expand Down Expand Up @@ -802,10 +777,18 @@ impl ZellijPlugin for State {
// start-or-reload-plugin`): stamp the build so the zellij log tells
// you WHICH wasm produced a trace. Set by the rebuild recipe via
// CLAVE_BUILD_TAG; "dev" means an untagged local build.
// The client id rides along (2026-09-22): zellij routes every
// swap-layout ask by the client this instance was loaded under
// (zellij-server 0.45.1 plugins/zellij_exports.rs:120-140, then
// screen.rs:10372 `active_tab_and_connected_client_id!`), and
// ids are the lowest free number (lib.rs:663-672), so a tab minted
// by a CLI client inherits an id the next CLI client will reuse.
let ids = get_plugin_ids();
eprintln!(
"clave-bar: loaded v{} build={}",
"clave-bar: loaded v{} build={} client={}",
env!("CARGO_PKG_VERSION"),
option_env!("CLAVE_BUILD_TAG").unwrap_or("dev")
option_env!("CLAVE_BUILD_TAG").unwrap_or("dev"),
ids.client_id
);
// #44: resolve the CLI from plugin configuration instead of PATH. A
// stale `clave` on PATH previously served a live session's `clave
Expand Down Expand Up @@ -1009,7 +992,6 @@ impl ZellijPlugin for State {
is_focused: p.is_focused,
is_floating: p.is_floating,
terminal_command: p.terminal_command.clone(),
is_held: p.is_held,
exited: p.exited,
exit_status: p.exit_status,
});
Expand Down Expand Up @@ -1147,6 +1129,17 @@ impl ZellijPlugin for State {
// the deafness a few ms early is harmless, and no expiry can
// strand it.
let fx = self.model.width_cooldown_elapsed();
// The cooldown's own asks were unlogged until 2026-09-22:
// `render` logs only the asks it makes, and this leg makes
// the rest. The QA counter reads both lines.
for e in &fx {
if let Effect::SwapWidth { backwards } = e {
let last = self.last_cols;
eprintln!(
"clave-bar: swap-width backwards={backwards} cols={last:?} source=cooldown"
);
}
}
let width_moved = !fx.is_empty();
self.run_effects(fx);
// The term-poll leg (#206): re-probe, re-arm while wanted,
Expand Down Expand Up @@ -1223,7 +1216,48 @@ impl ZellijPlugin for State {
// pane id the request carries (v0.44.3 — FOOTGUNS.md). The gate lives in
// `width_effects`, which holds the switch until this bar's own tab is
// the focused one.
// Every width change zellij paints (2026-09-22): the only trace of
// a swap that landed on this pane, or landed and was undone.
if self.last_cols != Some(cols) {
let was = self.last_cols;
eprintln!("clave-bar: painted cols={cols} was={was:?}");
self.last_cols = Some(cols);
}
let fx = self.model.width_effects(Some(cols));
// One log line per width ask, SHIPPED: it is the only observable
// of the flap the devbox had (2026-09-22: sixteen asks in four
// seconds, every one for the width the pane already had). The QA
// drive counts these lines per sandbox instance; a bar at its width
// asks nothing, so any ask during a nav walk is a defect. Cheap:
// a healthy bar asks at most once per toggle.
// Own tab, focused tab and the own tab's tiled pane count ride
// along (2026-09-22): zellij applies a swap to the focused tab, and
// an ask that never lands needs those three to name the seam.
for e in &fx {
if let Effect::SwapWidth { backwards } = e {
let tab = self.model.own_tab();
let active = self.model.active_tab_id();
let panes = self.model.own_tab_tiled_pane_count();
let client = get_plugin_ids().client_id;
eprintln!(
"clave-bar: swap-width backwards={backwards} cols={cols} tab={tab:?} active={active:?} panes={panes:?} client={client}"
);
}
}
// The silent case (2026-09-22): a paint at the wrong width with no
// ask. One line per (width, reason), so a bar resting wrong for a
// minute costs one line, not one per frame.
let deaf = self.model.width_deaf_reason(cols).map(|r| (cols, r));
if deaf != self.last_deaf {
if let Some((_, reason)) = deaf {
let tab = self.model.own_tab();
let active = self.model.active_tab_id();
eprintln!(
"clave-bar: width-deaf cols={cols} reason={reason} tab={tab:?} active={active:?}"
);
}
self.last_deaf = deaf;
}
self.run_effects(fx);
// One line per row, display-ordered. Everything visual — the column
// arithmetic, the palette, the fade, the truncation — lives in
Expand Down
Loading
Loading