Building Cockpit: A Terminal Dashboard for the Multi-Project Developer
- go
- tui
- tmux
- tooling
- productivity
Everyone who works across multiple projects develops a ritual. You open a terminal, run tmux ls to see what’s alive, cd into three repos to check git status, open Obsidian to see today’s tasks, then check GitHub for failing CI. By the time you’ve assembled the picture, five minutes are gone and you’ve lost the thread of whatever you were about to do.
I built cockpit to collapse that ritual into one screen. It’s a Go TUI that runs inside tmux and shows you everything at a glance: active sessions, repo status, today’s tasks, inbox items, and aggregated signals that tell you what needs attention. One command, always running in the background, always ready when you tmux attach.
Tmux-native by design
Cockpit doesn’t replace tmux. It lives inside it. When you run cockpit, it either attaches to an existing cockpit tmux session or creates one. Your projects live in their own sessions as usual. Cockpit is just another session you flip to when you need the overview.
The key interaction: you highlight a session in the Sessions panel and press Enter. Cockpit detaches itself and attaches you directly to that session. When you’re done, tmux switch-client -t cockpit brings you back. The mental model is a home screen you return to between tasks.
This was a deliberate constraint. I didn’t want a standalone app that needed its own window management. tmux already handles persistence, detach/reattach, and window organization. Cockpit just needs to read state and render it.
Five sources, one dashboard
The architecture is simple: five independent data sources feed five panels. Each source polls on its own interval and owns its own state.
type Sources struct {
Tmux *tmux.Client
Git *git.Client
Obsidian *obsidian.Client
GitHub *github.Client
Calendar *calendar.Client
}
Tmux sessions are the primary signal. The source lists all sessions with window counts, attachment status, and how long they’ve been idle. This is the “what projects do I have open?” answer.
Git repos are polled in parallel using goroutines. Each configured repo gets a status check: current branch, dirty file count, unpushed commits, commits behind remote, and last commit message.
func GetGitStatus(repos []RepoConfig) []RepoStatus {
var wg sync.WaitGroup
results := make([]RepoStatus, len(repos))
for i, repo := range repos {
wg.Add(1)
go func(idx int, r RepoConfig) {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
results[idx] = queryRepo(ctx, r)
}(i, repo)
}
wg.Wait()
return results
}
The 10-second timeout per repo matters. One slow NFS mount or a repo with a massive index shouldn’t freeze the entire dashboard. Each goroutine is independent, and a timeout just means that repo shows stale data until the next poll succeeds.
Obsidian tasks are plain Markdown. Cockpit reads today.md and inbox.md from a configurable vault path, parses checkbox lines with regex, and renders them as interactive lists. Press x to toggle a task, and it writes the change back to the file immediately.
GitHub polls via the gh CLI for PR counts and CI status. This runs on a slower 60-second interval to stay well under API rate limits.
Calendar is macOS-specific. It shells out to osascript to query Calendar.app for events in the next N minutes. Time-sensitive meetings show up as high-priority signals.
The panel layout
The TUI is built with bubbletea and uses a responsive grid that recalculates when the terminal resizes.
The top section shows tmux sessions as horizontal cards. The middle row splits into repo status (left) and today’s tasks (right). The bottom row has inbox items (left) and aggregated signals (right). A key-hint bar at the bottom shows context-sensitive bindings.
Each panel is its own bubbletea model with independent cursor position, scroll state, and rendering logic. Focus moves between panels with Tab, and j/k navigates within the focused panel.
func CalculateLayout(width, height int) Layout {
sessionsHeight := int(float64(height) * 0.40)
middleHeight := int(float64(height) * 0.30)
bottomHeight := height - sessionsHeight - middleHeight - 2
if sessionsHeight < 6 { sessionsHeight = 6 }
if middleHeight < 5 { middleHeight = 5 }
if bottomHeight < 5 { bottomHeight = 5 }
return Layout{
SessionsHeight: sessionsHeight,
MiddleHeight: middleHeight,
BottomHeight: bottomHeight,
LeftWidth: width / 2,
RightWidth: width - width/2,
}
}
I considered fixed-height panels but they broke on small terminals. The ratio-based approach with floor constraints means the dashboard is usable on anything from a 80x24 terminal to a full ultrawide.
Obsidian as the task backend
No database. No sync service. Cockpit reads and writes plain Markdown files that Obsidian also manages. This was the most opinionated decision and the one I’m happiest with.
The task format is just GitHub-flavored checkboxes:
- [ ] Ship cockpit v1
- [x] Fix the layout bug on small terminals
- [ ] Write the blog post
Cockpit parses these with a simple regex, renders them with Lipgloss styling, and writes changes back atomically:
func (c *Client) ToggleTask(index int) error {
tasks, raw := c.parseTasks()
if index >= len(tasks) {
return fmt.Errorf("index out of range")
}
task := tasks[index]
old := task.Raw
if task.Done {
new := strings.Replace(old, "[x]", "[ ]", 1)
raw = strings.Replace(raw, old, new, 1)
} else {
new := strings.Replace(old, "[ ]", "[x]", 1)
raw = strings.Replace(raw, old, new, 1)
}
return atomicWrite(c.todayPath, raw)
}
The atomicWrite writes to a temp file first, then renames it into place. If Cockpit crashes mid-write, the original file is untouched. This matters because Obsidian is watching the same files and could pick up a partial write.
The alternative was building a task API or using a SQLite store. But then you’d need sync logic between the database and Obsidian, conflict resolution, and a migration path. Plain files mean Cockpit and Obsidian are always looking at the same source of truth. Edit a task in Obsidian’s UI, and Cockpit sees it on the next poll. Toggle one in Cockpit, and Obsidian picks it up immediately.
Quick capture without context switching
The c key opens an inline text input in the Today panel. Type a task, press Enter, and it appends to today.md. But the more useful entry point is the CLI:
cockpit cap "review the PR for auth changes"
This appends a timestamped unchecked task to inbox.md and exits. No TUI, no tmux attachment required. I use it from whatever session I’m in when a thought hits. The inbox shows up in the bottom-left panel for triage later.
The interaction pattern is deliberate: capture fast into the inbox, promote to today during review, complete from the dashboard. Three stages, each with its own panel.
Signals: what needs attention right now
The Signals panel is the reason cockpit exists. It aggregates attention-worthy items from all five sources and ranks them by urgency:
- Calendar events: anything in the next 15 minutes
- Failing CI: red builds on any configured repo
- Unpushed commits: work that’s done locally but not shared
- Commits behind remote: branches that need a pull
- Stale sessions: tmux sessions idle for more than 24 hours
- Dirty repos: uncommitted changes
func BuildSignals(sessions []Session, repos []RepoStatus, github *GitHubStatus, events []CalendarEvent) []Signal {
var signals []Signal
for _, e := range events {
if e.MinutesUntil <= 15 {
signals = append(signals, Signal{
Level: Critical,
Source: "calendar",
Message: fmt.Sprintf("%s in %dm", e.Title, e.MinutesUntil),
})
}
}
for _, r := range repos {
if r.CIStatus == "failing" {
signals = append(signals, Signal{
Level: High,
Source: r.Label,
Message: "CI failing",
})
}
if r.Unpushed > 0 {
signals = append(signals, Signal{
Level: Medium,
Source: r.Label,
Message: fmt.Sprintf("%d unpushed commits", r.Unpushed),
})
}
}
sort.Slice(signals, func(i, j int) bool {
return signals[i].Level < signals[j].Level
})
return signals
}
The thresholds are configurable. You can disable stale session warnings if you intentionally leave sessions open for days. You can crank the calendar lookahead to 30 minutes if you want more lead time. The defaults are what work for me.
Before signals, I’d context-switch into a project only to discover CI had been red for hours. Or I’d forget I had three commits unpushed on a branch a teammate was waiting on. The signals panel turned reactive discovery into passive awareness.
Configuration: one TOML file
Everything lives in ~/.config/cockpit/config.toml. The cockpit init command generates a starter config by scanning your tmux sessions and prompting for paths:
[general]
session_name = "cockpit"
refresh_interval = 5
[obsidian]
vault_path = "~/Documents/Vault"
today_file = "Cockpit/today.md"
inbox_file = "Cockpit/inbox.md"
[[repos]]
path = "~/workspace/orch"
label = "orch"
[[repos]]
path = "~/workspace/personal-site"
label = "site"
[github]
enabled = true
refresh_interval = 60
[signals]
stale_session_threshold = "24h"
show_stale_sessions = true
show_unpushed = true
show_failing_ci = true
The [[repos]] array uses TOML’s array-of-tables syntax. Each repo gets a path and a human-readable label that shows up in the Repos panel. Adding a new project means adding three lines to the config.
What doesn’t work
Obsidian sync conflicts. If you edit a task on your phone via Obsidian Sync while Cockpit is writing to the same file on your laptop, you’ll get a conflict file. The atomic write prevents corruption, but it doesn’t prevent divergence. In practice this rarely happens because I only toggle tasks from one device at a time, but it’s a known sharp edge.
Calendar on non-macOS. The osascript approach is inherently macOS-only. Linux users would need a different calendar source, probably calcurse or a CalDAV client. I haven’t built the abstraction yet because I only use macOS, and premature generalization is how simple tools become complex ones.
Large repo counts. The parallel git polling works fine up to about 20 repos. Beyond that, you start hitting file descriptor limits and the git commands compete for disk I/O. The timeout prevents hangs, but the status data can go stale. For my use case (8-12 active repos), this hasn’t been a problem.
The stack
- Go with cobra for CLI and bubbletea for TUI
- Lipgloss for styling with Catppuccin Mocha colors
- TOML for configuration (BurntSushi/toml)
- tmux and gh as external dependencies
- About 3,100 lines of Go
Cockpit solves a narrow problem: I want to glance at one screen and know what’s happening across all my projects. It runs in the background, stays out of the way, and surfaces the right information at the right time. That’s it.