Case Study

Navi

Terminal dashboard for monitoring Claude Code sessions

Category

Developer Tools

Status

Active

Maturity

Prd

Problem and Motivation

If you run 6 Claude Code sessions in tmux, you already know the pain. One of them needs a permission approval, another one's burning through tokens, and you're just cycling through panes trying to figure out which is which. I got tired of that loop pretty fast.

I wanted one screen that showed every session's status, what branch it's on, how many tokens it's used, and let me attach to any of them with a single keypress. No browser, no daemon, just another tmux pane that stays out of the way.

Architecture Overview

The whole thing is a Bubble Tea app that polls JSON status files off the filesystem. Claude Code has a hooks system, so I wrote a shell script (notify.sh, about 414 lines of bash by now) that fires on events like prompt submission, permission requests, and tool use. Each hook invocation writes a JSON file to ~/.claude-sessions/<session>.json with the session's current state.

The TUI side is straightforward: a 500ms tick reads all those JSON files, normalizes them into session.Info structs, and feeds them into Bubble Tea's update loop. A separate 5-second tick polls git info for each session's working directory, caching branch name, dirty state, and ahead/behind counts. PR metadata gets fetched lazily through the gh CLI only when you actually open the git detail dialog, because hitting GitHub's API on every poll would be wasteful.

The task system is probably the most over-engineered part. It discovers .navi.yaml configs in session working directories, shells out to provider scripts (GitHub Issues, a markdown parser for my delivery docs), caches results with a TTL, and renders them as collapsible task groups with progress bars. It works, but the provider timeout handling and cached error states added more complexity than I expected.

Remote sessions work by SSHing into configured machines in parallel, reading their ~/.claude-sessions/ directories, and merging the results into the same view. A connection pool keeps SSH handshakes from killing the poll interval.

Key Technical Decisions and Tradeoffs

  1. File-based discovery instead of an API

    The hooks write JSON files, the TUI reads JSON files. No protocol, no daemon, no ports. Installation is just merging a hook config and copying a binary. The tradeoff is a 500ms polling floor and filesystem I/O on every tick, but for a directory with maybe 10 JSON files that's nothing. I tried thinking about whether a socket-based approach would be better and decided it wasn't worth it for the complexity it'd add.

  2. Go + Bubble Tea instead of a web dashboard

    I wanted something that starts instantly and lives inside tmux, not beside it. Bubble Tea's model-update-view loop is clean and testable, which matters because navi has 20+ custom message types at this point. The real cost is layout math. There's no CSS here, so things like the preview pane (side or bottom, resizable with [ and ], with independent scroll) all need manual viewport arithmetic. That code is tedious to write and easy to get wrong.

  3. Parallel SSH fan-out for remote sessions

    Each configured remote gets its own goroutine. Results come back through a channel and get merged into the local session list.

func PollSessions(pool *SSHPool, remotes []Config) []session.Info {
    results := make(chan SessionsResult, len(remotes))
    var wg sync.WaitGroup
    for _, remote := range remotes {
        wg.Add(1)
        go func(r Config) {
            defer wg.Done()
            sessions, err := PollSingleRemote(pool, r)
            results <- SessionsResult{RemoteName: r.Name, Sessions: sessions, Error: err}
        }(remote)
    }
    go func() { wg.Wait(); close(results) }()
    // ... merge results
}

SSH connections are expensive to set up and break easily. The connection pool helps, but I still had to deal with tilde expansion on the remote side ($HOME instead of ~ inside single quotes) and graceful fallback when a remote disappears. It's the part of the codebase with the most edge case handling.

  1. Task providers as external scripts

    Providers are just executables that print JSON to stdout. They get arguments as NAVI_TASK_ARG_* environment variables and have a 30-second timeout before navi kills them. Built-in ones are shell scripts in providers/. It's slower than parsing in-process but it means anyone can write a provider in any language without touching Go code. I'm still not sure if shelling out was the right call vs. embedding a Lua interpreter or something, but it works and the caching layer hides the latency.

Screenshots and Video

No screenshots yet. Planning to capture the multi-session dashboard, the preview pane during active coding, and the git detail dialog showing PR check status.

Tech Stack with Rationale

  • Go because I needed goroutines for the SSH fan-out and git polling, and I wanted a single binary with zero runtime dependencies. go build and you're done.
  • Bubble Tea for the TUI framework. The model-update-view pattern keeps state changes predictable even with 20+ message types bouncing around (session ticks, git ticks, preview captures, task refreshes, PR auto-refresh). Testing is decent too, you can unit test the update function.
  • Lip Gloss for terminal styling. It's the Bubble Tea ecosystem's answer to CSS. I defined all the status badges, progress bars, and scroll indicators as Lip Gloss style constants. It's fine, though the layout compositing can get verbose.
  • tmux is the whole substrate. Navi creates sessions (tmux new-session), kills them, renames them, attaches to them, and captures their pane output for the preview feature. Without tmux there's no project.
  • SSH for remote sessions. No agent required on the remote side, just cat ~/.claude-sessions/*.json over an SSH connection. The pool keeps connections alive between polls.
  • GitHub CLI (gh) for PR metadata. I didn't want to deal with GitHub's API auth directly. gh pr view --json gives me everything I need and it handles token management.

Challenges and Learnings

  • Scroll math was the worst part. Three independent scrollable viewports (session list, preview pane, task panel) each with their own scroll indicators that eat into the visible area. I lost a full day to off-by-one errors in viewport clamping when the scroll indicators themselves reduce the number of visible items. It's correct now but that code is not fun to read.
  • The preview pane needed 100ms debouncing after cursor movement. Without it, rapidly pressing j to scroll through sessions fires a tmux capture-pane on every keystroke and the whole thing jitters. Small fix, but I didn't realize the cause for a while because it only showed up during fast navigation.
  • The notify.sh hook script is 414 lines of bash and honestly that's too much bash. It handles teammate events, stale session guards, atomic writes via temp files, and metric accumulation. Every time I add a feature that touches session state I have to update both the Go TUI and the bash hook, which means two languages for one data flow. I'd probably write it in Go if I started over.
  • PR auto-refresh (polling checks every 30 seconds while CI is pending) turned out to be one of the most useful features. I can watch a deploy pipeline finish without leaving the terminal or opening a browser tab. Didn't plan for it initially, it came out of wanting to know when my checks passed so I could merge.
  • Remote SSH support taught me that "just SSH in and read some files" has a surprising number of edge cases. Tilde expansion doesn't work inside single quotes. Connection drops need graceful recovery without blocking the main poll loop. And you can't use the local git working directory to fetch PR info for remote sessions, so I had to add the gh pr view -R owner/repo fallback path.

Links