Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,12 @@ The ISSUES MODAL (`i`) is the same idea for GitHub issues: the TUI lists the sel
| `nebula-core` | Shared protocol, entities, IDs, paths, codec |
| `nebula-daemon` | PTYs, SQLite, git, hook receiver, status engine |
| `nebula-tui` | ratatui UI, keyboard/mouse, attach/scrollback |
| `nebula-fuzzy` | The list filters' matcher β€” dependency-free, and a crate of its own only so dev builds optimise it (`[profile.dev.package.nebula-fuzzy]`) |

The TUI also has extras on top of the multiplexer: git diff viewer, grep, the root checkout's branch switcher, a vim-like terminal overlay, fuzzy finders β€” those are client-side. The daemon is the source of truth for sessions and the tree.

**Input is not action.** In `nebula-tui` a key arm and a mouse arm only translate: they say *which row* (`list_hit::row_at` for the pointer) and then call the one function that says *what choosing it does* β€” `event_loop::activate` for the panels and the modals `event_loop` owns, a modal's own `activate_selected` or `Cmd` executor (`run_settings_cmd`, `file_tabs::run`, `preset_overlays::activate_selected`) for the rest, `select_clicked_row` / `select_*_row` for moving a cursor, `context_menu_items` for `m` and the right button alike. Nothing that closes a modal, sends a request, spawns a process or moves a cursor lives inline in `handle_mouse` or in a key arm beside a twin that does the same. The rule exists because its absence shipped bugs: a click in a pull request's preset picker launched a plain session into the ROOT WORKTREE while Enter launched the PR SESSION, a click in the file finder skipped the markdown reader Enter had learned, and a right-click moved the cursor without the pane. The `INPUT PARITY` tests in `event_loop.rs` build the same app twice, choose a row once by key and once by pointer, and compare everything observable.

**A key handler never blocks.** `nebula-tui`'s event loop is one task: while a handler runs nothing paints, no PTY output is parsed and no other key is read. So a handler does bookkeeping and nothing else β€” anything that spawns a process, reads a file of unknown size or waits on the network is a BACKGROUND READ (`view_jobs.rs`: the worktree views' git and disk, keyed by ticket so a late answer nobody is waiting for is dropped) or one of the per-feature channels `main_loop` owns (`gh`, the BRANCH SWITCHER, issues), and a view built without a handle β€” every unit test β€” reads inline through the same parsers. What the DAEMON will confirm is shown first (`event_loop/optimistic.rs`, `event_loop/placeholder.rs`) and rolled back on Error. `event_loop/pacing.rs` decides when the loop may paint. The INPUT LATENCY PROBE (`perf.rs`, `NEBULA_PERF_LOG`) and `make perf` are how a change to any of it is judged: handler, paint, settle and echo per scripted step, with peak RSS beside them, because holding more to feel faster is not a trade this codebase makes β€” the pane's screen cache got quicker by holding less.

**Mental model:** tmux, but the β€œwindows” are agent CLIs bound to git worktrees, and the sidebar is a mission-control view of which agents are working, waiting, or dead.
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["crates/nebula-core", "crates/nebula-daemon", "crates/nebula-tui", "crates/nebula"]
members = ["crates/nebula-core", "crates/nebula-daemon", "crates/nebula-fuzzy", "crates/nebula-tui", "crates/nebula"]
exclude = ["vendor/vt100"]

# Vendored vt100 with one patch: rows scrolled out of a top-anchored DECSTBM
Expand All @@ -19,6 +19,7 @@ license = "MIT"
nebula-core = { path = "crates/nebula-core" }
nebula-daemon = { path = "crates/nebula-daemon" }
nebula-tui = { path = "crates/nebula-tui" }
nebula-fuzzy = { path = "crates/nebula-fuzzy" }

serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand All @@ -41,6 +42,15 @@ directories = "6"
[profile.dev.package."*"]
opt-level = 3

# …and the one workspace crate that is a hot loop and nothing else: the
# fuzzy matcher runs over every path of a checkout on every keystroke of
# the FILE FINDER. Unoptimised that is 13–27 ms a character over ten
# thousand paths, felt as a filter that lags the typing; it is a few
# hundred lines that rarely change, so optimising it costs the edit-build
# loop nothing.
[profile.dev.package.nebula-fuzzy]
opt-level = 3

[profile.release]
lto = "thin"
# Keep the symbol table (drop only debug info) so the crash-log panic
Expand Down
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ DEV_ENV = NEBULA_RUNTIME_DIR=$(DEV_RUNTIME) NEBULA_DATA_DIR=$(DEV_DATA) \
$(if $(AGENT),NEBULA_AGENT_CMD=$(AGENT))

.DEFAULT_GOAL := help
.PHONY: help dev browser dev-prep dev-seed dev-reset dev-ls dev-stop build install kill prune cycle check fmt lint test ci clean shot
.PHONY: help dev browser dev-prep dev-seed dev-reset dev-ls dev-stop build install kill prune cycle check fmt lint test ci clean shot perf

help: ## Show this help
@grep -hE '^[a-z][a-z-]*:.*?## ' $(MAKEFILE_LIST) \
Expand Down Expand Up @@ -126,6 +126,15 @@ dev-reset: dev-stop ## Wipe this checkout's dev data; the next `make dev` re-see
shot: ## Screenshot the debug TUI with demo data (SCENE=open-prs KEYS="…")
scripts/shot/shot.sh $(SCENE)

# The LATENCY HARNESS: the same isolation as `make shot`, against a clone of this repository, with the
# INPUT LATENCY PROBE on (NEBULA_PERF_LOG). Drives scripts/perf/scenario.steps β€” every panel, modal and
# verb β€” and prints per step how long the key held the loop, how long it waited for its frame, how long
# the screen took to settle, and the TUI's and daemon's peak RSS. `make perf BIN=target/release/nebula`
# measures the release build; `python3 scripts/perf/report.py BEFORE AFTER` compares two runs.
perf: ## Measure input latency per action in the debug TUI (BIN=… OUT=… PERF_DUMP=1)
cargo build -q
scripts/perf/run.sh

# Slots accumulate: a worktree you deleted leaves its DB behind under
# ~/.nebula-dev. This lists every one with its daemon's state, so you can see
# what is still running and `rm -rf` what is not.
Expand Down
5 changes: 3 additions & 2 deletions crates/nebula-core/src/mem.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Small memory probes shared by the daemon and the TUI client. All of them
//! shell out (macOS has no /proc) and only run on the metrics modal's slow
//! poll, never on a hot path.
//! shell out (macOS has no /proc), so none of them belongs on an event
//! loop: the daemon runs its sweep on the blocking pool, and the TUI reads
//! its own RSS there too β€” the footer's readout asks every five seconds.

/// Resident set size of one process, bytes.
pub fn process_rss_bytes(pid: u32) -> Option<u64> {
Expand Down
58 changes: 55 additions & 3 deletions crates/nebula-daemon/src/pty/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ use tokio::sync::{broadcast, mpsc};
const RING_CAPACITY: usize = 1024 * 1024;
/// Flush coalesced output at this size…
const COALESCE_BYTES: usize = 8 * 1024;
/// …or this long after the first pending byte, whichever comes first. A hard
/// …or this long after the previous flush, whichever comes first. A hard
/// deadline (not a quiet-gap timer): a child streaming continuously in small
/// chunks must still flush on time, or output arrives in laggy 8KB lumps.
/// Counted from the last flush rather than from the first pending byte, so
/// output that breaks a silence β€” the echo of a typed character, a prompt
/// redrawn after Enter β€” is not held at all (`flush_deadline`).
const COALESCE_HOLD: std::time::Duration = std::time::Duration::from_millis(5);
/// Reader thread β†’ pump channel bound; blocking_send gives natural
/// backpressure against a fire-hosing child.
Expand Down Expand Up @@ -521,6 +524,25 @@ fn spawn_reader_thread(
.expect("spawn pty reader thread");
}

/// When output that arrived at `now` has to be on its way to the clients.
/// The hold exists to turn a stream of small writes into fewer, larger
/// events β€” so it is spent only while there is a stream: a flush less than
/// [`COALESCE_HOLD`] ago means more is likely right behind, and the bytes
/// wait out the rest of that window. After a quiet spell they go at once.
/// That is every keystroke's echo: held, it reached the TUI 5 ms late on
/// every character typed into a pane, which the INPUT LATENCY PROBE put at
/// half of what the key took to show. A stream still flushes at most once
/// per hold, exactly as before; the one extra event is at its head.
fn flush_deadline(
now: tokio::time::Instant,
last_flush: Option<tokio::time::Instant>,
) -> tokio::time::Instant {
match last_flush {
Some(last) if now < last + COALESCE_HOLD => last + COALESCE_HOLD,
_ => now,
}
}

/// Drains the reader channel: append to the ring (always β€” detach is free),
/// coalesce bursts, broadcast to whoever is attached.
async fn pump(session: Arc<PtySession>, mut rx: mpsc::Receiver<ReaderMsg>) {
Expand Down Expand Up @@ -568,6 +590,7 @@ async fn pump(session: Arc<PtySession>, mut rx: mpsc::Receiver<ReaderMsg>) {
}
};

let mut last_flush: Option<tokio::time::Instant> = None;
'outer: loop {
if pending.is_empty() {
match rx.recv().await {
Expand All @@ -580,10 +603,14 @@ async fn pump(session: Arc<PtySession>, mut rx: mpsc::Receiver<ReaderMsg>) {
}
}
// Coalesce until the deadline or the size cap; the deadline is fixed
// at the first pending byte so continuous streams still flush on time.
let deadline = tokio::time::Instant::now() + COALESCE_HOLD;
// when the first pending byte arrives so continuous streams still
// flush on time. Biased toward the channel: a deadline that has
// already passed (the quiet-spell case) still takes along whatever
// the reader has queued, so one write read in two pieces is one event.
let deadline = flush_deadline(tokio::time::Instant::now(), last_flush);
while pending.len() < COALESCE_BYTES {
tokio::select! {
biased;
msg = rx.recv() => match msg {
Some(ReaderMsg::Data(d)) => pending.extend_from_slice(&d),
Some(ReaderMsg::Eof { exit_code }) => {
Expand All @@ -600,6 +627,7 @@ async fn pump(session: Arc<PtySession>, mut rx: mpsc::Receiver<ReaderMsg>) {
}
}
flush(&session, &mut pending);
last_flush = Some(tokio::time::Instant::now());
}
tracing::info!(session = ?session.sref, "pty pump ended");
}
Expand All @@ -609,6 +637,30 @@ mod tests {
use super::*;
use nebula_core::AgentId;

/// Output that breaks a silence is not held: a typed character's echo
/// leaves the DAEMON the moment it is read.
#[test]
fn output_after_a_quiet_spell_is_flushed_at_once() {
let now = tokio::time::Instant::now();
assert_eq!(flush_deadline(now, None), now, "the session's first bytes");
let long_ago = now - COALESCE_HOLD * 10;
assert_eq!(flush_deadline(now, Some(long_ago)), now);
assert_eq!(flush_deadline(now, Some(now - COALESCE_HOLD)), now);
}

/// A stream is still coalesced: bytes arriving inside the hold of the
/// last flush wait for that hold to end, so the event rate under
/// sustained output is what it was β€” one flush per hold at most.
#[test]
fn output_inside_the_hold_waits_for_it_to_end() {
let now = tokio::time::Instant::now();
let just_flushed = now - std::time::Duration::from_millis(1);
assert_eq!(
flush_deadline(now, Some(just_flushed)),
just_flushed + COALESCE_HOLD
);
}

fn echo_session() -> Arc<PtySession> {
PtySession::spawn(
SessionRef::Agent(AgentId::generate()),
Expand Down
22 changes: 16 additions & 6 deletions crates/nebula-daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,12 +562,22 @@ async fn handle_client(daemon: Arc<Daemon>, stream: UnixStream) -> Result<()> {
message,
} => {
tracing::info!(agent = %id, bytes = message.len(), "send to cloud session");
reply_done(
&out_tx,
req_id,
daemon.send_cloud_message(&id, &message).await,
)
.await;
// `claude -p … --cloud` is a login shell and a network
// round trip β€” seconds. Off the request loop, like the
// worktree ops above: run inline, every keystroke and
// every session switch on this connection waited for
// it, and the pane the user went back to typing in
// looked hung until the message was sent.
let daemon = daemon.clone();
let out_tx = out_tx.clone();
tokio::spawn(async move {
reply_done(
&out_tx,
req_id,
daemon.send_cloud_message(&id, &message).await,
)
.await;
});
}
ClientRequest::CreateTerminal {
req_id,
Expand Down
10 changes: 10 additions & 0 deletions crates/nebula-fuzzy/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "nebula-fuzzy"
version.workspace = true
edition.workspace = true
license.workspace = true

# No dependencies, on purpose: this crate exists so that dev builds can
# optimise it (see `[profile.dev.package.nebula-fuzzy]` in the workspace
# manifest) without recompiling anything else at opt-level 3.
[dependencies]
Loading
Loading