feat: first-class herdr multiplexer support — native splits + classic attach (WIP) - #69
feat: first-class herdr multiplexer support — native splits + classic attach (WIP)#69salemsayed wants to merge 42 commits into
Conversation
Adds herdr (https://herdr.dev) as a runtime-supported multiplexer type alongside tmux and zmx, following the generic Multiplexer abstraction: availability detection, JSON session listing, attach-or-create launch, and attach-only guard with graceful shell fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Serializable models for the herdr api snapshot document (workspaces, tabs, panes with agent status) with a lenient agent-status decoder that maps unknown values to Unknown for protocol forward-compatibility. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Streams herdr api snapshots over a dedicated client-paced SSH exec channel (one connection, newline-per-poll), with idle backoff, poke on mutation, exponential retry, and teardown on disconnect/reconnect. Adds focus/create/close control verbs via the existing exec command envelope. Also replaces Thread.sleep with cancellable delay in readExecOutput. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Renders herdr workspaces, tabs, and panes with agent-status badges, tap-to-focus, tab create/close, and refresh, sourced from the engine snapshot stream. ViewModel is a thin pass-through of engine state and verbs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Posts throttled notifications when a herdr agent transitions to blocked or done while the app is backgrounded, with tap-to-focus of the source tab. Polling cadence now follows app visibility: 3s active tab, 15s inactive, 25s background, 60s when notifications are off. Toggle lives in terminal settings, default on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
When the active session is a herdr multiplexer, the always-visible strip renders the focused workspace's herdr tabs with agent-status dots and tap-to-focus, a host chip opening the tab manager, and a plus button that creates a herdr tab. Falls back to the session strip otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Adds layouts[] models (tab area, per-pane rects in terminal cells, focused pane, zoom flag) needed to render herdr tabs as native splits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Codec for the herdr terminal stream plane: terminal.frame/closed parsing, ndjson framing, input/resize/scroll encoders, seq-gap disposition, and the control/observe stream command builder. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Adds HerdrPaneHost, which drives a per-pane herdr terminal.frame stream into a dedicated ghostty VT: control/observe modes with automatic read-only fallback when another client holds control, frame-header authoritative resize, seq-gap restart, and input/scroll uplink. The engine owns a map of pane hosts with ensure/retain/focus/takeover/stop lifecycle and a pure desiredHerdrPaneStreams reconciliation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Repository tracks the focused herdr pane and routes key/text/paste/scroll/ focus to its pane host (suppressing mouse and full-canvas resize in native mode), reconciles visible pane streams from the layout, and follows server-side focus changes. Engine flattens pane-host state into herdrPaneStates; ViewModel exposes herdrNativeState + pane tap/viewport/ scroll/takeover handlers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Adds HerdrSplitLayout, which scales herdr's pane geometry onto the screen and renders one native terminal per pane (focused pane interactive, others tap-to-focus), with connecting/error placeholders, zoom, and a read-only takeover chip. TerminalScreen shows it in place of the single canvas when native mode is active; herdr native launch runs a keepalive channel instead of the TUI so the splits own the screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Persists a per-host herdrNativeMode flag (default on, Room v12 migration) that gates native splits vs classic TUI attach, threaded through TabSpec/ConnectionParams and exposed as a "native splits (beta)" switch in Add Server when herdr is selected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Pane streams were only created by the canvas onResize, but the canvas only mounts once a snapshot exists, which needs a running stream — a deadlock that left every pane stuck on "connecting…". Reconciliation now creates desired-but-missing streams with a bootstrap geometry; the canvas refines it once it mounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Focus changes previously reconnected each pane (control<->observe swap = fresh SSH handshake), making switching slow. Panes are distinct herdr terminals that can each hold control concurrently, so every visible pane now opens and holds its own control stream; focus is pure input routing. Switching between panes is instant. Read-only fallback and takeover for panes another client controls are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
The persistent connection tab now renders a native herdr home listing workspaces and agents with live status; tapping a workspace switches to it and tapping an agent jumps into its pane as native splits. Adds the agent snapshot model, a workspace-focus verb, a home-visible state that resets on connect, and a home chip in the tab strip. This gives workspace switching and makes the connection tab the herdr navigator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Pane VTs are created after the app's color scheme is set, so they fell back to ghostty's dark default while earlier panes rendered light. Apply the engine's pending color scheme and default colors to each pane host on creation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Adds an additive channel table (up to 16 exec channels) on the native SSH session plus JNI to open, exchange (read/write), query EOF, and close individual exec channels, so one connection can carry many simultaneous streams. Existing single-channel shell/exec/pty/SFTP paths are unchanged (writeChannel/readChannel now take an explicit channel; legacy callers pass the single channel). Teardown frees all table channels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Introduces SharedSshConnection: one authenticated SSH session per active herdr session, with pane streams and control verbs multiplexed as separate exec channels over it instead of a fresh SSH handshake each. All channel ops are serialized on a dedicated per-connection dispatcher (libssh2 is not thread-safe), and connection setup is guarded by a mutex so concurrent pane starts share one connection. Pane retries reopen a channel rather than reconnecting. Collapses per-pane and per-verb handshakes (~0.8s each over a high-latency link) into cheap channel opens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Split panes used fitSnapshotToCanvas=true (the command-palette preview mode), which scales the grid to fit and letterboxes when the grid aspect differs from the pane rect. Interactive panes should render at native cell size like the main terminal (false); each pane's grid is already resized to fill its rect via onResize, so it fills without bars. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
A pane's real-size onResize often fires while its stream channel is still connecting, so the resize command was dropped and the pane stayed at the bootstrap 80x24 forever (content overflowed narrow split rects). After a channel opens, re-send a terminal.resize with the latest known geometry so herdr renders at the pane's actual size. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Panes were sized independently by width, so rounding accumulated and the last pane extended past the screen edge, clipping its content. Compute each pane's box from its scaled left/right and top/bottom edges instead, so adjacent panes share exact borders and the rightmost/bottom pane ends exactly at the container edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Scroll delta mapped to the reversed herdr scroll direction; flip it so dragging scrolls content the natural way. Tapping a pane always raised the soft keyboard, making focus switching in a split annoying; now tapping a non-focused pane only focuses it, and tapping the already focused pane raises the keyboard for typing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Native herdr previously ran the tmux/zmx-style multiplexer session-picker preflight on connect, which showed an empty "no terminal sessions" state until an SSH round-trip resolved a session (or the user tapped "new connection"). Native mode always attaches to the default session, so open the tab directly and skip the preflight. Also force the tab strip when the active tab is native herdr, since the split UI and herdr tab navigation depend on it and break under the palette tab interface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Native herdr mode replaced the cross-server tab strip with herdr's own tabs, leaving no easy way to reach other server connections. Add a "connections" section at the top of the herdr home listing every open connection tab (tap to switch) plus a "server list" entry, so the home is the single place to move between servers and herdr workspaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
The connections list and session hint showed chuchu's random per-tab alias (e.g. "amber-signal"), which didn't match the server names shown in the server list and tab endpoints. Show the server name (tabLabel) instead, falling back to the alias only as a secondary hint when more than one connection targets the same host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
The workspace header showed herdr's single aggregate agent_status, which read "working" even when only one of several agents was working and "unknown" for workspaces with no agents. Compute the summary from the workspace's actual agents instead: a per-status count (e.g. "2 working 1 idle"), and nothing at all when a workspace has no agents. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Per herdr's status semantics, blocked (waiting for input) and done (finished, result unseen) are the states that require the user, while idle/working are passive. Surface them at the top of the home: a "needs you" list of blocked/done agents across the session, blocked first, tap to jump straight to the pane. Hidden when nothing needs attention. This turns the home into a triage screen and complements the existing blocked/done notifications. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
- Pluralize tab/pane counts ("1 tab · 1 pane").
- Collapse workspaces to a header + status summary by default; auto-expand
the focused workspace and any with a blocked/done agent, so the home
stays scannable while surfacing what matters.
- Drop the agent-row cwd when it just repeats the workspace name, and
strip the repeated workspace prefix/suffix from agent titles.
- Add spacing between workspace groups.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Tapping a herdr agent/tab/workspace runs a focus verb over the shared SSH connection. If that connection had gone stale (e.g. the phone's IP changed), openExecChannel threw "Channel open timed out" which propagated uncaught to the main thread and crashed the app. Catch failures in the control path: surface them as an action error, and drop the stale shared connection so it reconnects on the next command. Also guard the ViewModel launch so no control-verb exception can reach the main thread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Thread an optional herdr session name through the snapshot stream, pane terminal streams, and control verbs, emitting `herdr --session <name>` when the configured session is not the default. Behavior is unchanged for the default session (no flag emitted); this is groundwork for a per-host herdr session selector. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Add a "+ new" action to the home's workspaces header that prompts for an optional name and creates a focused herdr workspace (herdr workspace create --focus [--label]). Previously workspaces could only be created from the herdr TUI/CLI on the host; the app could enter and switch workspaces but not add one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
The connect flow only bypassed the tmux/zmx-style session-picker preflight for native herdr; classic herdr (native splits off) still ran it and showed the empty "no terminal sessions" state until the user manually tapped "new connection". herdr is single-primary-session in both modes, so any herdr host now attaches to the default session directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
The strip's herdr-tab mode was gated on "is a herdr host" rather than native mode, so in classic mode (native splits off) chuchu's tab strip mirrored herdr's tabs — duplicating the tab bar herdr already draws inside its own TUI. Gate it on native mode so classic mode shows normal chuchu connection tabs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Native mode routes keystrokes to the focused pane's PTY, so herdr's own keybindings for creating/destroying panes never reach the server. Add the missing CLI control-plane verbs (pane split --direction right|down, pane close, workspace close) alongside the existing focus/create-tab verbs so these can be driven from native chuchu UI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Native mode routes keystrokes to the focused pane's PTY, so herdr's own keybindings for creating/destroying panes never reach the server. Surface these actions explicitly instead: - A compact '+' menu on the tab strip (split right, split down, close pane, new tab) acting on the focused pane/tab. - Long-press a herdr tab to close it. - A close (✕) on each workspace row in the switcher home. Destructive closes go through a confirmation dialog since they terminate any running agent. Built with the app's own Popup + ChuText rows (no material3, which the module doesn't depend on). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
262c769 to
765c563
Compare
Newly opened pane streams used a hardcoded 80x24 bootstrap until the canvas measured and resized, so switching to a workspace whose tab holds one pane flashed a full-screen TUI (e.g. a coding agent) at the wrong size before the resize round-trip landed. Remember the last real geometry a native canvas reported and reuse it when a single new pane opens (a lone pane fills the whole area, so it is exact); splits keep the safe 80x24 fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
Workspace expansion was tied to server focus, so the only way to reveal a workspace's tabs/panes was to enter it (navigating away) and come back. Add a caret toggle that expands a workspace in place: agent-bearing tabs list their panes (tap to jump to that pane) and agent-less tabs show a tab row (tap to focus that tab). Auto-expand for focused/attention workspaces is preserved as the default when the user hasn't toggled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
The per-pane rectangular border framed every pane in a heavy box that doubled up at shared edges. Drop borders entirely: separate panes with a faint hairline gutter (2+ panes only) and indicate focus by dimming the inactive panes (iTerm2-style) so the active pane stays full brightness. A single pane keeps its clean full-bleed rendering — no border, gutter, or dim. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxaFcjTqr7Pny76bzzGFdD
c65193c to
65601cd
Compare
Wire onFontSizeChange into HerdrSplitLayout so pinch-to-zoom works in
focused herdr native panes (was a dead onZoom = {}), and include the
stepped-zoom canvas rework: anchored whole-sp steps with haptic ticks,
pointerInput(Unit) + rememberUpdatedState holders so committed steps no
longer restart the gesture mid-pinch, and the absolute onFontSizeChange
API with minFontSizeSp/maxFontSizeSp bounds.
The canvas/call-site portion duplicates upstream PR jossephus#70; once that merges,
rebase drops it here and keeps only the herdr wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01634UryFkSv94cq7S4WD6vq
|
Hey @salemsayed, keep making updates on the PR. Although i doubt we will land this to main, i can see how useful it can be. a while ago, i was working on plugins to chuchu, i got distracted by other stuff but the ultimate goal was to enable customizations like this. Let me see if I can push on the plugin implementation this week, and i will be using this PR as the demo. also loving herdr so far |
Oh a plugin is exactly the correct way for this, I also agree that it it's too far to land on main but I've been using it for a couple of days and I think it's really beneficial for working with herdr and especially on a small phone as the herdr tui is hard to manage on a phone when you've adjusted it on your big screen computer. |
The leading chip in native mode was a static "⌂ herdr", so there was no way to tell which workspace you were in without opening the switcher. Label it with the focused workspace instead — label, falling back to "ws <number>", then "herdr" when the snapshot has no match. Tap behavior (open switcher home) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01634UryFkSv94cq7S4WD6vq
…ic focus Tab switching in native mode took ~1.2s measured on-device: ~185ms for the herdr focus CLI exec, ~250ms for the next control snapshot poll, and ~790ms re-opening pane streams, because pane hosts were disposed the instant a tab lost focus. Two changes, benchmarked at each step: - Warm pane streams: desiredHerdrPaneStreams keeps the panes of up to two recently focused tabs alive alongside the focused tab's (12-stream cap, zoomed recent tabs contribute only their focused pane). The repository tracks per-connection recency; everything still drops when the app is backgrounded. Switch-back cost fell from ~1.2s to ~375ms, with content already live on arrival. - Optimistic focus: tapping a tab immediately swaps the layout, strip highlight, pane focus, and stream policy to the target using the layout geometry already present in the snapshot, then confirms via the focus command in the background. Failure or a 4s reconciliation timeout snaps back to snapshot truth; switching to another chuchu connection clears the override so it can never bleed across sessions. Warm switches now land in the same frame as the tap (~0ms perceived); cold tabs start their streams at tap time behind an instant layout swap. Adds policy unit coverage for recency, caps, override, and background gating in HerdrPaneStreamPolicyTest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01634UryFkSv94cq7S4WD6vq
What this adds
First-class support for herdr — a terminal multiplexer built for orchestrating coding agents (workspaces → tabs → panes, each pane running an agent with an
idle/working/blocked/donestatus). This wires herdr into chuchu the same way iTerm2 integrates tmux withtmux -CC: chuchu can either take over herdr's UI and render it natively, or attach to herdr's own TUI over a plain SSH session.herdr is driven entirely through its JSON CLI (
herdr api snapshot,herdr terminal session control|observe <pane>, focus/create/close verbs), so there's no wrapper service and no herdr-specific network protocol — it's the sameMultiplexerTypeabstraction the tmux/zmx support already uses, extended with a runtime control plane.Two modes (per-host toggle)
Native splits (default-on, hidden setting). chuchu hides herdr's TUI entirely and becomes the multiplexer chrome:
herdr terminal session control|observe <pane> --cols N --rows M, which emits an ndjsonterminal.framestream (server re-renders ANSI at the exact geometry chuchu asks for). Each pane is fed into its own native ghostty VT and laid out in a native split that mirrors herdr's own layout geometry (layouts[]from the snapshot, in cell units).terminal.input/terminal.resize/terminal.scrolluplink. Tap a pane to focus; tap the focused pane to raise the keyboard.done= finished + unseen (wants your attention),idle= already seen. Focusing a pane flipsdone → idleserver-side, so the triage list stays honest.pane split --direction right|down,pane close,tab create/close,workspace create/close). A compact+menu on the tab strip (split right / split down / close pane / new tab) acts on the focused pane/tab; long-press a tab to close it; the switcher home has+ newand a✕per workspace. Destructive closes go through a confirmation since they terminate the running agent.Classic attach (toggle off). Exactly today's behavior — a normal SSH session attached to herdr's own TUI, with chuchu's terminal rendering it. No native takeover, no extra streams. This is the fallback for anything the native path doesn't cover yet (e.g. inline images, which aren't in the frame stream).
Screenshots
Switcher home + "needs you" triage (native mode)
Native splits — two panes rendered side-by-side, focused pane has the accent border, layout mirrors herdr's own geometry:
How it's built (commit ladder)
Each commit is independently green (
./gradlew testDebugUnitTest+compileDebugKotlin); the native path stays inert until the "render herdr tabs as native splits" commit flips it on:parse herdr layout geometry in snapshot— kotlinx.serialization models + fixtures + parser testsadd herdr terminal frame codec— ndjson framing, seq/restart policy,terminal.input/resize/scrollencoders (pure + tested)stream herdr panes into native terminals—HerdrPaneHost(one VT + one channel per pane) + engine stream reconciliationroute input to the focused herdr pane— focus state + sink branchingrender herdr tabs as native splits—HerdrSplitLayout+ the flipadd per-host herdr native splits toggle—HostProfile.herdrNativeMode+ Room v11→v12 AutoMigration + AddServer switchAlignment with the repo's conventions
MultiplexerType/MultiplexerRegistryabstraction; no wrapper services, no herdr daemon.org.json).Testing status
done), and workspace-create verified on-device.+ pane →splits (new pane, focus follows),✕ pane→ confirm → pane closes and the layout reflows, with the neighbouring pane's session surviving.Tested on an Oppo Pad Mini against herdr 0.7.4 (protocol 16) over SSH.
How to try it
Would love feedback on the herdr abstraction and whether you'd rather see this as one PR or staged. 🤖 Generated with Claude Code