Skip to content
rsdlt.dev
Go back

Building levi, Day Two: The Polish That Makes a Terminal UI Usable

This is the fourth post in the Building levi series. The previous one ended at 01:07 on October 16, with a code search that answers in a fraction of a second. levi was fast by then, but it wasn’t pleasant to use yet. Two more sessions fixed that: the afternoon of October 16 (13:07 to 17:14) and the morning of October 17 (09:45 to 14:02). Together they shipped ten releases, 0.5.0 through 0.12.0, and grew the test suite from 63 tests to 86.

This post covers that polish, grouped by what it fixed rather than strictly by time.

Diffs that read like git show

The commit message on top

Until 0.5.0, the diff pane showed only the patch. Now it opens with the commit’s header and full message, laid out like git show. Fetching it costs nothing extra: the same git show call that returns the patch also prints the message, separated by a NUL byte, so it lands in the same cache entry.

Only the changed words

When a line is edited, the diff shows the old and new versions as a - line and a + line, and your eye has to find the difference. levi now marks just the words that changed, in reverse video, like git’s diff-highlight script:

The comparison is quadratic, which is a problem for huge rewrites. Each diff gets a fixed budget:

/// Word-by-word comparison is quadratic in the tokens a pair differs in, so
/// one diff gets this many steps of it. Past that, or for a single pair that
/// would cost more, the whole span between the shared ends is marked instead.
const BUDGET: usize = 1 << 20;

Past the budget, levi falls back to marking everything between the shared ends, which is what diff-highlight does and costs linear time. From the plan’s measurements:

InputWithout a budgetWith it
5,000 pairs of 200-word lines2 s39 ms
20,000 typical code pairsn/a18 ms

The mouse, without the cost

0.6.0 added clicks and the scroll wheel. A click selects a commit or file, and the wheel moves whichever list is under the pointer, or scrolls the diff, without moving focus.

Capturing the mouse has two costs, and levi deals with both:

The same afternoon brought levi’s first crash fix. In a terminal too small for any list rows, keeping the selection in view moved the scroll offset past it, and the row arithmetic underflowed. The fix is one condition. It came with a test that draws levi at 0×0, 10×3 and 100×5.

Fitting real terminals

let [commits, files, diff] = if body.width < STACK_BELOW {
    let [lists, diff] =
        Layout::vertical([Constraint::Percentage(35), Constraint::Percentage(65)]).areas(body);
    let [commits, files] =
        Layout::horizontal([Constraint::Percentage(55), Constraint::Percentage(45)])
            .areas(lists);
    [commits, files, diff]
} else {
    Layout::horizontal([
        Constraint::Percentage(25),
        Constraint::Percentage(20),
        Constraint::Percentage(55),
    ])
    .areas(body)
};

The layout follows the terminal’s width on every frame, so there’s no toggle key. Every printable key belongs to the search bar anyway.

Browsing a revision: levi main..HEAD

0.9.0 lets levi take one optional revision, anything git log understands:

levi main..HEAD      # what your branch adds to main, before you open the PR
levi v1.0..          # everything since a tag, for the changelog
levi other-branch    # another branch, without checking it out

This feature was small because of an earlier decision. The parallel loader from the previous post starts from git rev-list HEAD. Passing the revision there instead, as git rev-list --end-of-options <rev> --, narrows the whole pipeline at once. Search, /c and Enter all work unchanged on the smaller set, and /c gets faster too. The --end-of-options and -- keep git from reading the argument as an option or a path. A bad revision or an empty range fails with one line before the TUI opens.

The details that are easy to skip

October 17 went to the kind of work that rarely makes a demo:

/// Drops every color. What a background color marked, like the selection or a
/// code match, is shown in reverse video instead, so it still stands out.
fn strip_colors(buf: &mut Buffer) {
    for cell in &mut buf.content {
        if cell.bg != Color::Reset {
            cell.modifier.insert(Modifier::REVERSED);
        }
        cell.fg = Color::Reset;
        cell.bg = Color::Reset;
    }
}

Doing it once, on the finished frame, means no drawing code has to know about NO_COLOR.

Shipping it for real

A name clash

levi was already taken on crates.io. The package became levi-git with a binary named levi, so cargo install levi-git still installs a levi command. The README says why in one line.

A demo GIF without personal data

The README’s demo is recorded with vhs from a script, on levi’s own history. The first version showed the real author email in the commits, so the script now records a throwaway clone whose emails are rewritten to levi@survey.corps. The /c search in the demo is also typed in two halves, so the script file never contains the term it searches for and can’t show up in its own results.

Release binaries for every platform

Pushing a version tag now builds levi on GitHub Actions for five targets and attaches the archives, with their sha256 checksums, to the GitHub release:

TargetNotes
macOS, Apple Silicon and Intel
Linux x86_64 and ARM64Static musl builds that run on any distro
Windows x86_64Built, not yet battle-tested

If the release doesn’t exist yet, the workflow creates it, using that version’s section of the changelog as the release notes. cargo binstall levi-git downloads the same archives, and Homebrew installs from a tap: brew install rsdlt/tap/levi.

How Claude Code fit in

These sessions were less about algorithms and more about many small, careful changes. The same habits from the earlier sessions carried them:

Takeaways

The series continues as levi grows. The next post covers what came after 0.12.0.


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
Building levi, After Midnight: A 55-Second Code Search Down to 0.16 Seconds