Skip to content
rsdlt.dev
Go back

Building levi, Saturday Evening: Getting a Find Out to Your Clipboard, Your Editor and Your Agent

This is the fifth post in the Building levi series. The previous one ended with 0.12.0 at 14:02 on October 17: a fast, polished viewer. Until then, the only thing you could take out of levi was the sha that Enter prints. The session that evening, from 19:34 to 21:27, opened three more ways out: the clipboard, a headless search for scripts and agents, and your editor.

It shipped three releases, 0.13.0 through 0.14.1, and grew the test suite from 86 tests to 102.

Paths that keep their file name

The first commit of the evening fixed a small annoyance in deep repositories. A path like app/frontend/src/components/frontend/... filled the files column before the file name ever showed. Long paths now lose their start instead, whole leading directories first:

…/components/frontend/Button.tsx

Renames follow git diff --stat and write the shared directories once, as in src/{old.rs → new.rs}. When even the part in braces can’t fit, the row shows only the new path, so a cut never lands inside the rename. The fuzzy-match highlights move with the visible text.

Copying: Ctrl-Y and the mouse

Two ways at once

Ctrl-Y copies the full sha of the selected commit and keeps levi open. Enter prints it and quits. The status line says what was copied until the next key.

Copying from a terminal program has no single right way, so levi’s new clipboard module uses two, together:

Neither one reports back, so levi doesn’t try to choose:

/// Copies `text`. Neither way reports back, so this can't fail; at worst the
/// clipboard is left as it was.
pub fn copy(text: &str) {
    // stderr is the terminal levi draws on.
    let mut terminal = io::stderr();
    let _ = write!(terminal, "\x1b]52;c;{}\x07", base64(text.as_bytes()));
    let _ = terminal.flush();

    let text = text.to_owned();
    thread::spawn(move || {
        for tool in tools() {
            if pipe(tool, &text) {
                break;
            }
        }
    });
}

The sequence goes to stderr because levi draws there. stdout stays reserved for the sha. The app state only records what to copy, and the main loop, which owns the terminal, sends it.

Drag to copy

Day two’s mouse support dropped drag events to keep the mouse cheap, which also took away the terminal’s native selection. Four minutes after Ctrl-Y, levi got its own:

The selection lives in content coordinates, so scrolling doesn’t move it. Only left-button drags reach the app. Motion and other drags are still dropped in the input thread, as before.

levi —filter: the same search, no TUI

Claude Code and other coding agents run commands, but they can’t work a TUI. levi --filter gives them levi’s search anyway. It runs a query the way the search bar would and prints one <sha> <subject> line per match, best first, like fzf --filter:

$ levi --filter 'ui.rs /c STACK_BELOW'
d3a93ab5f5c98621cbaf9079d0c952786239d221 Stack the lists above the diff on narrow terminals

The whole syntax works: fuzzy terms, /c code search and /r for the reflog, plus an optional revision. Three details make it usable from scripts:

The README gained a short brief to paste into CLAUDE.md or AGENTS.md, so an agent knows the tool exists and how to query it.

--filter also turned out to be a benchmark harness. The speed numbers in the README all run through it, so anyone can rerun them with a stopwatch and no terminal tricks.

Hunk jumps across files

Shift-Down jumped to the next hunk, but stopped at the end of the file. Now it carries on to the next file’s first hunk, and Shift-Up from the first hunk goes back to the previous file’s last one, like ]c in Vim. Only the ends of the commit stop it.

The next file’s diff may still be loading when you press the key. The jump waits as a pending move that lands when the diff arrives, and any other key cancels it.

Ctrl-O: open the file at the line you’re reading

Ctrl-O opens the selected file in your editor, at the line showing at the top of the diff. levi works out that line from the hunk headers.

Handing over the terminal needed a change in the input thread. It used to block on reads, and a blocked read would steal the editor’s first keystroke. It now polls in 100 ms spells and can be paused: it parks, and says so, before the editor starts.

Files that aren’t there anymore

Seventeen minutes later, Ctrl-O learned to open files that are gone from the working tree: deleted, renamed since, or in a bare repository. levi writes the file as git has it in that commit (or in its parent, for a file the commit deletes) to a temporary directory, keeping its name so the editor still highlights it. The status line says it’s read-only history. The copy is removed when the editor quits. Neovim’s copy stays, since its buffer may still be open, and levi marks the buffer nomodifiable.

The same evening, levi got a recipe for running it in a Neovim floating window on <leader>gl, tested in a real Neovim. It’s now in docs/neovim.md.

The bug the demo found

0.14.0 shipped at 21:03. Re-recording the demo GIF to show Ctrl-O broke it within the half hour, because the demo runs levi as a picker, inside git show $(levi).

In that command, levi’s stdout is the pipe that carries the sha. Two things leaked into it:

The fix, in 0.14.1, gives the editor the terminal levi draws on, and /dev/tty for input when stdin isn’t a terminal:

// Simplified from src/editor.rs (the Unix half)
pub fn on_terminal(cmd: &mut Command) -> Result<()> {
    cmd.stdout(io::stderr().as_fd().try_clone_to_owned()?);
    if !io::stdin().is_terminal() {
        cmd.stdin(std::fs::File::open("/dev/tty")?);
    }
    Ok(())
}

The redraw now clears the screen on stderr and starts a fresh terminal handle, so nothing but the sha reaches stdout. No unit test had caught it, because the bug needed a real pipe, a real editor and a real terminal at once. The demo had all three.

How Claude Code fit in

Takeaways

The next post covers the morning after: syntax colors, a logo, and handing a find straight to an agent’s input.


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, Day Two: The Polish That Makes a Terminal UI Usable