Skip to content
rsdlt.dev
Go back

Building levi, Monday: Keeping Up With Commits From Another Terminal, and Panes You Can Drag

This is the seventh post in the Building levi series. The previous one ended on Sunday with 0.16.0: syntax colors, tints and a herdr plugin that hands a commit to your agent. On Monday, October 19, two short stretches of work shipped two releases. 0.17.0 at 13:43 made levi follow the repository while it’s open. 0.18.0 at 17:21 let you resize the panes with the mouse.

The test suite grew from 115 tests to 130.

The problem: a log frozen at startup

Until 0.17.0, levi read the history once, when it started. A commit, pull, amend or rebase made in another terminal, or by an agent, needed a restart to show. That matters more when an agent is committing while you read.

The plan’s new Refresh section sets the bar: levi follows the repository without a restart, “without a key or a setting.”

Asking git instead of watching files

The obvious tool is a file watcher on .git. The commit rejects it: it would add a dependency and miss worktrees, packed refs and bare repositories. Instead, a watcher thread asks git every 2 seconds what the history on screen resolves to:

Each look costs a few milliseconds of git. The watcher also checks at once when the terminal regains focus, through focus reporting (terminals without it ignore the request), and when Ctrl-O’s editor returns. levi --filter doesn’t watch: it runs once and exits.

Load only what’s new

Knowing that something changed is half the job. Reloading a big history every time someone commits would throw away the work levi does to load fast. So the watcher tells two cases apart:

The check is one function:

/// The revisions that cover just the new commits, if `old` only grew into
/// `new`: the same excluded tips, and each tip an ancestor of its new one.
/// `None` for anything else, like an amend or a rebase.
fn grown(root: &Path, old: &[String], new: &[String]) -> Option<Vec<String>> {
    let split = |tips: &[String]| -> (Vec<String>, Vec<String>) {
        tips.iter().cloned().partition(|t| !t.starts_with('^'))
    };
    let ((old_tips, old_excluded), (new_tips, new_excluded)) = (split(old), split(new));
    if old_excluded != new_excluded || old_tips.len() != new_tips.len() {
        return None;
    }
    for (o, n) in old_tips.iter().zip(&new_tips) {
        if o != n && !git::is_ancestor(root, o, n).ok()? {
            return None;
        }
    }
    let mut revs = new_tips;
    revs.extend(old_tips.iter().map(|t| format!("^{t}")));
    revs.extend(new_excluded);
    Some(revs)
}

git::is_ancestor wraps git merge-base --is-ancestor. Any error falls through to None, which means a full reload: the safe answer.

New commits go on top, not in date order. The plan explains why: a pull that brings older-dated commits shows them first, as what just arrived.

Keeping your place

A refresh that loses your search or your scroll position is worse than no refresh. So the query, the focused pane and the diff cache all stay. The cache is keyed by (sha, path), and commits never change, so its entries stay valid.

The selected commit stays selected by sha. After a prepend that happens at once. After a full reload it happens as soon as the batch holding it arrives, with its file and diff scroll. If a rewrite dropped the commit, the selection rests on the top commit.

A /c code search follows too. After a prepend, a finished search runs on the new commits only and adds their hits. An unfinished one starts over on its whole scope, keeping the hits it had. A reload reruns it.

The status line says what happened, until the next key or click: 2 new commits, history changed, reloaded or reflog updated.

The race

A watcher and a loader running side by side can miss a commit. The fix is to take the baseline in the main thread right before each full load starts. Anything committed after that shows up as a change on the next look. A commit that lands after the baseline but before the load’s rev-list gets loaded twice at worst, and prepends skip shas already on screen. A change during any load reloads in full rather than stacking partial loads.

The commit says how it was tested: in a real terminal under tmux, with commits, an amend and a checkout under /r.

Panes you can drag

0.18.0 came that evening. levi’s layout has three panes: commits, files and the diff, side by side on wide terminals and stacked on narrow ones. The splits were fixed. Now you drag the border between two panes to move it.

The layout code moved out of ui.rs into its own layout.rs, which now owns where the panes go and which seams a drag moves:

const MIN_WIDTH: u16 = 12;
const MIN_HEIGHT: u16 = 5;

/// Where the seams sit, as fractions of the body, so a terminal resize keeps
/// the proportions. Each layout keeps its own.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Splits {
    /// Side by side: where the commits end and where the files end, across.
    wide: [f32; 2],
    /// Stacked: where the lists end, down; where the commits end, across.
    stacked: [f32; 2],
}

What it doesn’t do

The plan records what was left out, and why:

How Claude Code fit in

Takeaways

The next post covers Tuesday’s 0.19.0, which shows the changes you haven’t committed yet.


I’m Rodrigo Santiago, a product leader who builds. I co-founded Foambyte with my brother Diego, a brewmaster who leads the product as CPO, and I designed and built the platform: architecture, frontend, backend, mobile and infrastructure. If you need someone who can own both the product and the code, let’s talk on LinkedIn.


Share this post:

Next Post
Weekly Roundup: TypeScript in Rust, Agent Skills Goes Neutral, a 68% Cheaper Code Reviewer, and Claude Duet