Building a lazygit-style terminal UI for GoCD in Rust
2026-08-22 · the GoCD API, an instant-feeling TUI, and the bugs a bug bash actually found.
I watch pipelines for a living, or close enough to it. GoCD's web dashboard is fine at a few dozen pipelines. It is not fine at 2,400, which is roughly where our instance sits. Every trigger, every log tail, every "did that deploy actually go out" check meant a page load. So I built lazygocd: the lazygit idea, applied to GoCD instead of git.
What the API actually looks like
GoCD's docs describe the shape of the API. Reading real responses from a real 2,400-pipeline server told me the rest.
The obvious approach, one status call per pipeline, doesn't scale: that's thousands of requests just to draw a dashboard. GoCD's /api/dashboard endpoint returns every group, every pipeline, and the latest run's status in one call. One request, one JSON blob, done. The versioned Accept header matters more than the docs suggest, too: application/vnd.go.cd.v4+json against that same endpoint returns the personalization envelope; drop a version and you get a different (often older, sometimes broken) response shape.
Some of the more useful endpoints aren't in the public docs at all. GoCD's own web dashboard has personalized views, the tabs you build yourself to slice thousands of pipelines down to the ones you own. That's /api/internal/pipeline_selection, an "internal" endpoint with no version header, guarded by an ETag on writes:
GET /api/internal/pipeline_selection # your saved views + ETag
PUT /api/internal/pipeline_selection # If-Match: <etag>, upsert by name
GET /api/dashboard?viewName=my-view # server-side filtered dashboard
Once I found that, lazygocd could read the views you'd already built in the web UI and let you switch between them with v, or save the pipelines matching a fuzzy filter as a new one with V. Nothing about that is documented. It's just what the web dashboard itself calls, visible in the network tab.
Making it feel instant
Fast and instant are different things. A dashboard call that takes two seconds is fast. Waiting two seconds every time you open the tool is not instant, and it's the difference that makes a TUI feel like a reflex instead of a chore.
- Disk cache on launch. The last dashboard payload is cached to
~/.config/lazygocd/. The tool paints from that cache immediately, then refreshes over it in the background. You never stare at a spinner on a tool you already used ten minutes ago. - ETag polling. The steady-state refresh sends
If-None-Matchwith the last ETag. When nothing changed, which is most of the time, GoCD answers 304 with an empty body instead of the full multi-megabyte payload. Idle polling costs almost nothing. - Incremental log tailing.
/files/.../console.logtakes a?startLineNumber=Nquery param. Following a running job's log used to mean re-downloading the whole file every three seconds; now it fetches only the lines that are new. - Hover-prefetch. Resting the cursor on a pipeline for 300ms starts loading its history in the background, so by the time you press enter it's already there.
None of these are exotic. They're the same handful of tricks every fast CLI tool uses. The GoCD API just happens to support all of them, once you go looking.
What a bug bash actually found
Testing a TUI is not like testing a web app. There's no browser devtools, no obvious "what did the user click." So the sharpest bugs came from an adversarial pass: a hostile mock server feeding the real binary unicode, malformed timestamps, and chaos-mashed input in a tmux session, with the real panics captured.
The most instructive one: a line like 12:34:56 日本語テスト looks like a timestamp followed by plain text. The code sniffed the first 13 bytes as a timestamp and split the string there. Byte 13 landed in the middle of a multibyte character. Rust panics hard on that, correctly, because slicing a string mid-character is undefined behavior for that type. The fix wasn't a special case for Japanese text; it was checking is_char_boundary before ever assuming an ASCII-width timestamp.
The search-highlight bug was subtler. Case-insensitive matching lowercased the line, searched the lowercased copy, then applied those byte offsets back to the original string. That works until a character's lowercase form has a different byte length than its uppercase form, at which point the offsets silently drift out from under you. The real fix was a matcher that walks both strings in lockstep by character, never by an offset borrowed from a different string.
Where it's going
The list of "what's next" came out of the same research pass as the bug bash: reading every peer TUI's feature set, and reading GoCD's API surface for the parts lazygocd doesn't touch yet. A few of the smaller, well-scoped pieces are open for anyone who wants to dig in:
- Server health indicator in the status bar
- Compare two runs (material diff between counters)
- Headless mode:
lazygocd status --json
If you run GoCD and any of this sounds familiar, the repo is at github.com/Sahilll15/lazygocd, and installing it is one line.