You are implementing Task 12 of the birdseye CLI implementation plan.

## Working Environment

- **Worktree path:** `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`
- **Git branch:** `worktree-birdseye`
- Recent commits include Task 11 (versions subcommand at `f0b337f`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

This task creates the bubbletea TUI scaffold for browsing a pre-computed `diff.Result`: left pane shows a file tree (status marker + path), right pane shows the colorized unified diff for the selected file, footer shows keybind hints. The Browser is just the rendering layer — it accepts the `diff.Result` as input and never makes network calls.

This task adds:
- `internal/tui/styles.go` — lipgloss theme
- `internal/tui/browser.go` — Browser struct + state helpers (Move, SelectedFile, View, etc.) — but NOT yet a `tea.Model` (no Update/Init)
- `internal/tui/browser_test.go` — non-Model tests

Task 13 (next) wires Browser as a `tea.Model` and adds key handling + the `RunBrowser` entry point.

The plan's code is verbatim. Strict TDD.

## Task Description (Verbatim)

### Task 12: `internal/tui` — styles and Browser model (no key handling yet)

**Files:**
- Create: `apps/birdseye/internal/tui/styles.go`
- Create: `apps/birdseye/internal/tui/browser.go`
- Create: `apps/birdseye/internal/tui/browser_test.go`

- [ ] **Step 1: Write failing test** at `apps/birdseye/internal/tui/browser_test.go`:

```go
package tui

import (
	"testing"

	"github.com/immuta/operations/apps/birdseye/internal/diff"
)

func sampleResult() diff.Result {
	return diff.Result{Files: []diff.FileDiff{
		{Path: "a.tf", Status: diff.StatusModified, Unified: "@@\n-x\n+y\n", Adds: 1, Dels: 1},
		{Path: "b.tf", Status: diff.StatusAdded, Unified: "+++ b.tf\n+new\n", Adds: 1},
	}}
}

func TestNewBrowserDefaults(t *testing.T) {
	b := NewBrowser(sampleResult())
	if b.SelectedIndex() != 0 {
		t.Errorf("default selected index = %d, want 0", b.SelectedIndex())
	}
	if b.SelectedFile().Path != "a.tf" {
		t.Errorf("first selected = %s", b.SelectedFile().Path)
	}
}

func TestBrowserViewContainsBothFiles(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	view := b.View()
	for _, want := range []string{"a.tf", "b.tf"} {
		if !contains(view, want) {
			t.Errorf("view missing %q\n%s", want, view)
		}
	}
}

func contains(s, sub string) bool {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}
```

- [ ] **Step 2: Run, expect FAIL**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/tui/...`
Expected: undefined symbols.

- [ ] **Step 3: Implement `apps/birdseye/internal/tui/styles.go`**

```go
// Package tui hosts the bubbletea presentation for birdseye. Models are
// pure — they never make network calls or read the filesystem. The
// browser displays a pre-computed diff.Result; the picker (Task 14)
// dispatches network work as tea.Cmds.
package tui

import "github.com/charmbracelet/lipgloss"

// Theme keeps every lipgloss style in one place for easy tweaking.
type Theme struct {
	FileTree     lipgloss.Style
	FileTreeItem lipgloss.Style
	FileSelected lipgloss.Style
	StatusAdd    lipgloss.Style
	StatusDel    lipgloss.Style
	StatusMod    lipgloss.Style
	Viewport     lipgloss.Style
	Footer       lipgloss.Style
	AddedLine    lipgloss.Style
	RemovedLine  lipgloss.Style
	HunkHeader   lipgloss.Style
}

// DefaultTheme returns a high-contrast theme that should look reasonable
// in both light and dark terminals.
func DefaultTheme() Theme {
	return Theme{
		FileTree:     lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1),
		FileTreeItem: lipgloss.NewStyle(),
		FileSelected: lipgloss.NewStyle().Bold(true).Reverse(true),
		StatusAdd:    lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
		StatusDel:    lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
		StatusMod:    lipgloss.NewStyle().Foreground(lipgloss.Color("11")),
		Viewport:     lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1),
		Footer:       lipgloss.NewStyle().Faint(true),
		AddedLine:    lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
		RemovedLine:  lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
		HunkHeader:   lipgloss.NewStyle().Foreground(lipgloss.Color("14")),
	}
}
```

- [ ] **Step 4: Implement `apps/birdseye/internal/tui/browser.go`**

```go
package tui

import (
	"strings"

	"github.com/charmbracelet/bubbles/viewport"
	"github.com/charmbracelet/lipgloss"

	"github.com/immuta/operations/apps/birdseye/internal/diff"
)

// Browser is the multi-pane diff viewer: file tree on the left,
// rendered unified diff on the right, footer at the bottom.
type Browser struct {
	result   diff.Result
	theme    Theme
	selected int
	width    int
	height   int
	viewport viewport.Model
}

// NewBrowser builds a Browser from a pre-computed Result. SetSize must
// be called once before the first View() so the viewport sizes itself.
func NewBrowser(r diff.Result) *Browser {
	vp := viewport.New(0, 0)
	return &Browser{
		result:   r,
		theme:    DefaultTheme(),
		viewport: vp,
	}
}

// SetSize informs the browser of the terminal dimensions.
func (b *Browser) SetSize(w, h int) {
	b.width, b.height = w, h
	fileTreeW := 32
	if w < 80 {
		fileTreeW = 24
	}
	contentW := w - fileTreeW - 4
	contentH := h - 2
	if contentW < 10 {
		contentW = 10
	}
	if contentH < 5 {
		contentH = 5
	}
	b.viewport.Width = contentW
	b.viewport.Height = contentH
	b.refreshViewport()
}

// SelectedIndex returns the currently focused file diff index.
func (b *Browser) SelectedIndex() int { return b.selected }

// SelectedFile returns the FileDiff at the current selection. Falls
// back to a zero value when the result has no files.
func (b *Browser) SelectedFile() diff.FileDiff {
	if len(b.result.Files) == 0 {
		return diff.FileDiff{}
	}
	return b.result.Files[b.selected]
}

// Move adjusts the selection by delta, clamping to the valid range, and
// refreshes the viewport content.
func (b *Browser) Move(delta int) {
	if len(b.result.Files) == 0 {
		return
	}
	b.selected += delta
	if b.selected < 0 {
		b.selected = 0
	}
	if b.selected >= len(b.result.Files) {
		b.selected = len(b.result.Files) - 1
	}
	b.refreshViewport()
}

func (b *Browser) refreshViewport() {
	if len(b.result.Files) == 0 {
		b.viewport.SetContent("no differences")
		return
	}
	b.viewport.SetContent(b.colorizeUnified(b.SelectedFile().Unified))
	b.viewport.GotoTop()
}

func (b *Browser) colorizeUnified(unified string) string {
	var sb strings.Builder
	for _, line := range strings.Split(unified, "\n") {
		switch {
		case strings.HasPrefix(line, "@@"):
			sb.WriteString(b.theme.HunkHeader.Render(line))
		case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
			sb.WriteString(b.theme.AddedLine.Render(line))
		case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
			sb.WriteString(b.theme.RemovedLine.Render(line))
		default:
			sb.WriteString(line)
		}
		sb.WriteByte('\n')
	}
	return sb.String()
}

// View renders the full multi-pane layout.
func (b *Browser) View() string {
	tree := b.renderTree()
	right := b.theme.Viewport.Render(b.viewport.View())
	body := lipgloss.JoinHorizontal(lipgloss.Top, tree, right)
	footer := b.theme.Footer.Render("j/k or ↑/↓ move · q quit · esc back")
	return lipgloss.JoinVertical(lipgloss.Left, body, footer)
}

func (b *Browser) renderTree() string {
	var sb strings.Builder
	for i, fd := range b.result.Files {
		marker := " "
		switch fd.Status {
		case diff.StatusAdded:
			marker = b.theme.StatusAdd.Render("+")
		case diff.StatusRemoved:
			marker = b.theme.StatusDel.Render("-")
		case diff.StatusModified:
			marker = b.theme.StatusMod.Render("~")
		}
		line := marker + " " + fd.Path
		if i == b.selected {
			line = b.theme.FileSelected.Render(line)
		} else {
			line = b.theme.FileTreeItem.Render(line)
		}
		sb.WriteString(line)
		sb.WriteByte('\n')
	}
	if len(b.result.Files) == 0 {
		sb.WriteString("(no files)")
	}
	return b.theme.FileTree.Render(sb.String())
}
```

- [ ] **Step 5: Run, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/tui/... -v`
Expected: 2 tests pass.

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/ apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): tui browser scaffold with multi-pane layout"
```

Drop go.mod/go.sum from `git add` if unchanged.

## Notes & Style

- This task deliberately omits `Init()` and `Update()` — Task 13 adds them. Don't add them now or you'll have to delete them.
- `bubbles/viewport` and `lipgloss` were already in go.mod from Task 1. If `go mod tidy` doesn't move them between direct/indirect blocks, that's fine.
- The view rendering uses lipgloss's `JoinHorizontal` / `JoinVertical` for layout. Don't hand-roll.
- The `contains` test helper in `browser_test.go` is a tiny utility because `strings.Contains` would otherwise require importing `strings` in the test file. Either works; the plan uses the manual version. Keep it.

## Code Organization

`styles.go` (theme only), `browser.go` (rendering only). No `_test.go` file beyond `browser_test.go`. Three files total.

## Self-Review Checklist

- [ ] Both tests pass under `-race`
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` clean
- [ ] No `Init()`, `Update()`, or `RunBrowser` defined yet (those are Task 13)
- [ ] Theme struct has all 11 lipgloss styles listed
- [ ] `Browser.SetSize(120, 30)` produces a viewport with non-trivial dimensions
- [ ] `Browser.View()` contains both file paths in the rendered tree
- [ ] Commit message exactly: `feat(birdseye): tui browser scaffold with multi-pane layout`
- [ ] Only `apps/birdseye/internal/tui/` files (plus optional go.mod/go.sum) staged

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created
- Test count + race-clean
- Commit SHA
- Anything unexpected


- migrate-flux-app: Use when migrating a Flux CD application from the infrastructure/kubernetes/application-deployments repo to the application-deployment repo. Covers copying app folders, updating kustomizations, committing, pushing, and creating merge requests.
- app-infra-generate: Generate an application-infrastructure Terraform module from a YAML spec produced by app-infra-spec. Reads pattern files and a versions manifest to produce main.tf, variables.tf, outputs.tf, overlay guidance, and a triage list.
- app-infra-spec: Structured questionnaire to gather requirements for an application-infrastructure Terraform module. Produces a YAML spec for the app-infra-generate skill.
- obsidian-load: Load context from Obsidian notes in the `Claude/` directory into the current conversation.
- obsidian-save: Save current conversation context to an Obsidian note under the `Claude/` directory.
- mind:search: Search memories for specific content or patterns
- mind:stats: Show memory statistics and storage information
- mind:recent: Show recent memories and activity timeline
- mind:ask: Ask questions about memories and get context-aware answers
- superpowers:using-git-worktrees: Use when starting feature work that needs isolation from current workspace or before executing implementation plans - ensures an isolated workspace exists via native tools or git worktree fallback
- superpowers:receiving-code-review: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
- superpowers:finishing-a-development-branch: Use when implementation is complete, all tests pass, and you need to decide how to integrate the work - guides completion of development work by presenting structured options for merge, PR, or cleanup
- superpowers:dispatching-parallel-agents: Use when facing 2+ independent tasks that can be worked on without shared state or sequential dependencies
- superpowers:writing-plans: Use when you have a spec or requirements for a multi-step task, before touching code
- superpowers:using-superpowers: Use when starting any conversation - establishes how to find and use skills, requiring Skill tool invocation before ANY response including clarifying questions
- superpowers:writing-skills: Use when creating new skills, editing existing skills, or verifying skills work before deployment
- superpowers:requesting-code-review: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
- superpowers:executing-plans: Use when you have a written implementation plan to execute in a separate session with review checkpoints
- superpowers:verification-before-completion: Use when about to claim work is complete, fixed, or passing, before committing or creating PRs - requires running verification commands and confirming output before making any success claims; evidence before assertions always
- superpowers:brainstorming: You MUST use this before any creative work - creating features, building components, adding functionality, or modifying behavior. Explores user intent, requirements and design before implementation.
- superpowers:systematic-debugging: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
- superpowers:test-driven-development: Use when implementing any feature or bugfix, before writing implementation code
- superpowers:subagent-driven-development: Use when executing implementation plans with independent tasks in the current session
- mind:memory: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- mind:mind: Claude Mind - Search and manage Claude's persistent memory stored in a single portable .mv2 file
- update-config: Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, suggest the /config command.
- keybindings-help: Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json. Examples: "rebind ctrl+s", "add a chord shortcut", "change the submit key", "customize keybindings".
- simplify: Review changed code for reuse, quality, and efficiency, then fix any issues found.
- fewer-permission-prompts: Scan your transcripts for common read-only Bash and MCP tool calls, then add a prioritized allowlist to project .claude/settings.json to reduce permission prompts.
- loop: Run a prompt or slash command on a recurring interval (e.g. /loop 5m /foo). Omit the interval to let the model self-pace. - When the user wants to set up a recurring task, poll for status, or run something repeatedly on an interval (e.g. "check the deploy every 5 minutes", "keep running /babysit-prs"). Do NOT invoke for one-off tasks.
- schedule: Create, update, list, or run scheduled remote agents (routines) that execute on a cron schedule. - When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/routines. Also use when the user wants a one-time scheduled run ("run this once at 3pm", "remind me to check X tomorrow").
- claude-api: Build, debug, and optimize Claude API / Anthropic SDK apps. Apps built with this skill should include prompt caching. Also handles migrating existing Claude API code between Claude model versions (4.5 → 4.6, 4.6 → 4.7, retired-model replacements).
TRIGGER when: code imports `anthropic`/`@anthropic-ai/sdk`; user asks for the Claude API, Anthropic SDK, or Managed Agents; user adds/modifies/tunes a Claude feature (caching, thinking, compaction, tool use, batch, files, citations, memory) or model (Opus/Sonnet/Haiku) in a file; questions about prompt caching / cache hit rate in an Anthropic SDK project.
SKIP: file imports `openai`/other-provider SDK, filename like `*-openai.py`/`*-generic.py`, provider-neutral code, general programming/ML.
- init: Initialize a new CLAUDE.md file with codebase documentation
- review: Review a pull request
- security-review: Complete a security review of the pending changes on the current branch


I'll implement Task 12 following strict TDD. Let me start by examining the existing codebase structure.


Bash

List internal package directories


Check go.mod for existing dependencies


drwxr-xr-x steve.briggs staff 288 B Tue May 12 23:12:50 2026 ..
drwxr-xr-x steve.briggs staff 128 B Tue May 12 23:28:06 2026 archive
drwxr-xr-x steve.briggs staff 192 B Tue May 12 23:37:34 2026 registry
drwxr-xr-x steve.briggs staff 160 B Tue May 12 23:39:15 2026 .
drwxr-xr-x steve.briggs staff 160 B Tue May 12 23:44:54 2026 diff


module github.com/immuta/operations/apps/birdseye

go 1.25.5

require (
	github.com/hexops/gotextdiff v1.0.3
	github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a
	github.com/spf13/cobra v1.9.1
	golang.org/x/sync v0.20.0
)

require (
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/spf13/pflag v1.0.6 // indirect
)

replace github.com/immuta/operations/services/crow => ../../services/crow

replace github.com/immuta/operations/libs/clog => ../../libs/clog


The go.mod doesn't yet have `lipgloss` or `bubbles`. Let me check what's actually available and look at the diff package to understand the types.


List diff package files


drwxr-xr-x steve.briggs staff 160 B  Tue May 12 23:39:15 2026 ..
.rw-r--r-- steve.briggs staff 2.2 KB Tue May 12 23:39:29 2026 textual_test.go
.rw-r--r-- steve.briggs staff 1.1 KB Tue May 12 23:39:42 2026 types.go
.rw-r--r-- steve.briggs staff 2.6 KB Tue May 12 23:44:54 2026 textual.go
drwxr-xr-x steve.briggs staff 160 B  Tue May 12 23:44:54 2026 .


cat /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/go.sum | grep -E "lipgloss|bubbles|bubbletea" | head -20

Check go.sum for TUI dependencies


(Bash completed with no output)


No TUI dependencies yet. Let me look at the diff types and check what Go modules are in the sum file.


1	// Package diff produces FileDiff entries and optional SemanticChange
2	// entries from a pair of extracted Modules. All functions are pure: no
3	// I/O, no globals.
4	package diff
5
6	// FileStatus describes what happened to a file between the two versions.
7	type FileStatus int
8
9	const (
10		StatusModified FileStatus = iota
11		StatusAdded
12		StatusRemoved
13	)
14
15	func (s FileStatus) String() string {
16		switch s {
17		case StatusAdded:
18			return "added"
19		case StatusRemoved:
20			return "removed"
21		default:
22			return "modified"
23		}
24	}
25
26	// FileDiff is a per-file diff result. Unified holds the rendered diff
27	// hunks; Adds/Dels are the count of added/removed lines for summary
28	// rendering.
29	type FileDiff struct {
30		Path    string
31		Status  FileStatus
32		Unified string
33		Adds    int
34		Dels    int
35	}
36
37	// SemanticChange is a single HCL-aware change. Kind is one of "resource",
38	// "variable", "output", "provider", "locals", "data", or "module".
39	type SemanticChange struct {
40		Kind   string
41		Name   string
42		Status FileStatus
43		Detail string
44	}
45
46	// Result is what cmd/diff renders. Semantic is nil unless --semantic was
47	// requested.
48	type Result struct {
49		Files    []FileDiff
50		Semantic []SemanticChange
51	}
52


Good. Now let me create the `tui` directory and write the failing test first.


Create tui directory


Write

package tui

import (
	"testing"

	"github.com/immuta/operations/apps/birdseye/internal/diff"
)

func sampleResult() diff.Result {
	return diff.Result{Files: []diff.FileDiff{
		{Path: "a.tf", Status: diff.StatusModified, Unified: "@@\n-x\n+y\n", Adds: 1, Dels: 1},
		{Path: "b.tf", Status: diff.StatusAdded, Unified: "+++ b.tf\n+new\n", Adds: 1},
	}}
}

func TestNewBrowserDefaults(t *testing.T) {
	b := NewBrowser(sampleResult())
	if b.SelectedIndex() != 0 {
		t.Errorf("default selected index = %d, want 0", b.SelectedIndex())
	}
	if b.SelectedFile().Path != "a.tf" {
		t.Errorf("first selected = %s", b.SelectedFile().Path)
	}
}

func TestBrowserViewContainsBothFiles(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	view := b.View()
	for _, want := range []string{"a.tf", "b.tf"} {
		if !contains(view, want) {
			t.Errorf("view missing %q\n%s", want, view)
		}
	}
}

func contains(s, sub string) bool {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return true
		}
	}
	return false
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/browser_test.go (file state is current in your context — no need to Read it back)


Step 2: Run to confirm it fails with undefined symbols.


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/tui/... 2>&1

Run failing test - expect undefined symbols


Error: Exit code 1
# github.com/immuta/operations/apps/birdseye/internal/tui [github.com/immuta/operations/apps/birdseye/internal/tui.test]
internal/tui/browser_test.go:17:7: undefined: NewBrowser
internal/tui/browser_test.go:27:7: undefined: NewBrowser
FAIL	github.com/immuta/operations/apps/birdseye/internal/tui [build failed]
FAIL


Good — expected failure. Now I need to add the TUI dependencies. Let me check what's available for bubbles/lipgloss/bubbletea.


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && GOFLAGS="" GOPROXY="https://proxy.golang.org,athens.infrastructure.immuta.io,direct" go get github.com/charmbracelet/lipgloss@latest github.com/charmbracelet/bubbles@latest 2>&1

Add lipgloss and bubbles dependencies


go: added github.com/aymanbagabas/go-osc52/v2 v2.0.1
go: added github.com/charmbracelet/bubbles v1.0.0
go: added github.com/charmbracelet/colorprofile v0.4.1
go: added github.com/charmbracelet/lipgloss v1.1.0
go: added github.com/charmbracelet/x/ansi v0.11.6
go: added github.com/charmbracelet/x/cellbuf v0.0.15
go: added github.com/charmbracelet/x/term v0.2.2
go: added github.com/clipperhouse/displaywidth v0.9.0
go: added github.com/clipperhouse/stringish v0.1.1
go: added github.com/clipperhouse/uax29/v2 v2.5.0
go: added github.com/lucasb-eyer/go-colorful v1.3.0
go: added github.com/mattn/go-runewidth v0.0.19
go: added github.com/muesli/termenv v0.16.0
go: added github.com/rivo/uniseg v0.4.7
go: added github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e


Note that `bubbles` and `lipgloss` are marked as `// indirect` since no Go source files import them yet. That's fine — they'll become direct once the source files are written. Now let me check if `bubbles` needs `bubbletea` as a dependency.


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && grep "bubbletea" go.sum go.mod 2>&1

Check if bubbletea was added transitively


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && GOPROXY="https://proxy.golang.org,athens.infrastructure.immuta.io,direct" go get github.com/charmbracelet/bubbletea@latest 2>&1

Check bubbles/viewport import requirements - need bubbletea for viewport


Verify bubbletea in go.mod


github.com/charmbracelet/bubbletea v1.3.10 // indirect


Good. Now let me implement `styles.go`.


Write

// Package tui hosts the bubbletea presentation for birdseye. Models are
// pure — they never make network calls or read the filesystem. The
// browser displays a pre-computed diff.Result; the picker (Task 14)
// dispatches network work as tea.Cmds.
package tui

import "github.com/charmbracelet/lipgloss"

// Theme keeps every lipgloss style in one place for easy tweaking.
type Theme struct {
	FileTree     lipgloss.Style
	FileTreeItem lipgloss.Style
	FileSelected lipgloss.Style
	StatusAdd    lipgloss.Style
	StatusDel    lipgloss.Style
	StatusMod    lipgloss.Style
	Viewport     lipgloss.Style
	Footer       lipgloss.Style
	AddedLine    lipgloss.Style
	RemovedLine  lipgloss.Style
	HunkHeader   lipgloss.Style
}

// DefaultTheme returns a high-contrast theme that should look reasonable
// in both light and dark terminals.
func DefaultTheme() Theme {
	return Theme{
		FileTree:     lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1),
		FileTreeItem: lipgloss.NewStyle(),
		FileSelected: lipgloss.NewStyle().Bold(true).Reverse(true),
		StatusAdd:    lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
		StatusDel:    lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
		StatusMod:    lipgloss.NewStyle().Foreground(lipgloss.Color("11")),
		Viewport:     lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).Padding(0, 1),
		Footer:       lipgloss.NewStyle().Faint(true),
		AddedLine:    lipgloss.NewStyle().Foreground(lipgloss.Color("10")),
		RemovedLine:  lipgloss.NewStyle().Foreground(lipgloss.Color("9")),
		HunkHeader:   lipgloss.NewStyle().Foreground(lipgloss.Color("14")),
	}
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/styles.go (file state is current in your context — no need to Read it back)


Now implement `browser.go`.


package tui

import (
	"strings"

	"github.com/charmbracelet/bubbles/viewport"
	"github.com/charmbracelet/lipgloss"

	"github.com/immuta/operations/apps/birdseye/internal/diff"
)

// Browser is the multi-pane diff viewer: file tree on the left,
// rendered unified diff on the right, footer at the bottom.
type Browser struct {
	result   diff.Result
	theme    Theme
	selected int
	width    int
	height   int
	viewport viewport.Model
}

// NewBrowser builds a Browser from a pre-computed Result. SetSize must
// be called once before the first View() so the viewport sizes itself.
func NewBrowser(r diff.Result) *Browser {
	vp := viewport.New(0, 0)
	return &Browser{
		result:   r,
		theme:    DefaultTheme(),
		viewport: vp,
	}
}

// SetSize informs the browser of the terminal dimensions.
func (b *Browser) SetSize(w, h int) {
	b.width, b.height = w, h
	fileTreeW := 32
	if w < 80 {
		fileTreeW = 24
	}
	contentW := w - fileTreeW - 4
	contentH := h - 2
	if contentW < 10 {
		contentW = 10
	}
	if contentH < 5 {
		contentH = 5
	}
	b.viewport.Width = contentW
	b.viewport.Height = contentH
	b.refreshViewport()
}

// SelectedIndex returns the currently focused file diff index.
func (b *Browser) SelectedIndex() int { return b.selected }

// SelectedFile returns the FileDiff at the current selection. Falls
// back to a zero value when the result has no files.
func (b *Browser) SelectedFile() diff.FileDiff {
	if len(b.result.Files) == 0 {
		return diff.FileDiff{}
	}
	return b.result.Files[b.selected]
}

// Move adjusts the selection by delta, clamping to the valid range, and
// refreshes the viewport content.
func (b *Browser) Move(delta int) {
	if len(b.result.Files) == 0 {
		return
	}
	b.selected += delta
	if b.selected < 0 {
		b.selected = 0
	}
	if b.selected >= len(b.result.Files) {
		b.selected = len(b.result.Files) - 1
	}
	b.refreshViewport()
}

func (b *Browser) refreshViewport() {
	if len(b.result.Files) == 0 {
		b.viewport.SetContent("no differences")
		return
	}
	b.viewport.SetContent(b.colorizeUnified(b.SelectedFile().Unified))
	b.viewport.GotoTop()
}

func (b *Browser) colorizeUnified(unified string) string {
	var sb strings.Builder
	for _, line := range strings.Split(unified, "\n") {
		switch {
		case strings.HasPrefix(line, "@@"):
			sb.WriteString(b.theme.HunkHeader.Render(line))
		case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
			sb.WriteString(b.theme.AddedLine.Render(line))
		case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
			sb.WriteString(b.theme.RemovedLine.Render(line))
		default:
			sb.WriteString(line)
		}
		sb.WriteByte('\n')
	}
	return sb.String()
}

// View renders the full multi-pane layout.
func (b *Browser) View() string {
	tree := b.renderTree()
	right := b.theme.Viewport.Render(b.viewport.View())
	body := lipgloss.JoinHorizontal(lipgloss.Top, tree, right)
	footer := b.theme.Footer.Render("j/k or ↑/↓ move · q quit · esc back")
	return lipgloss.JoinVertical(lipgloss.Left, body, footer)
}

func (b *Browser) renderTree() string {
	var sb strings.Builder
	for i, fd := range b.result.Files {
		marker := " "
		switch fd.Status {
		case diff.StatusAdded:
			marker = b.theme.StatusAdd.Render("+")
		case diff.StatusRemoved:
			marker = b.theme.StatusDel.Render("-")
		case diff.StatusModified:
			marker = b.theme.StatusMod.Render("~")
		}
		line := marker + " " + fd.Path
		if i == b.selected {
			line = b.theme.FileSelected.Render(line)
		} else {
			line = b.theme.FileTreeItem.Render(line)
		}
		sb.WriteString(line)
		sb.WriteByte('\n')
	}
	if len(b.result.Files) == 0 {
		sb.WriteString("(no files)")
	}
	return b.theme.FileTree.Render(sb.String())
}


File created successfully at: /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/browser.go (file state is current in your context — no need to Read it back)


Step 5: Run tests with race detector.


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/tui/... -v 2>&1

Run tests with race detector - expect 2 passes


=== RUN   TestNewBrowserDefaults
--- PASS: TestNewBrowserDefaults (0.00s)
=== RUN   TestBrowserViewContainsBothFiles
--- PASS: TestBrowserViewContainsBothFiles (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/tui	1.732s


Both tests pass, race-clean. Let me run the self-review checklist items.


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./... 2>&1 && go vet ./... 2>&1

Run go build and go vet


Verify Init/Update/RunBrowser not present


(Bash completed with no output)


Clean. No `Init`, `Update`, or `RunBrowser`. Now commit.


git add apps/birdseye/internal/tui/ apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "feat(birdseye): tui browser scaffold with multi-pane layout"

Stage tui files and commit


[worktree-birdseye d3eec79] feat(birdseye): tui browser scaffold with multi-pane layout
 5 files changed, 298 insertions(+)
 create mode 100644 apps/birdseye/internal/tui/browser.go
 create mode 100644 apps/birdseye/internal/tui/browser_test.go
 create mode 100644 apps/birdseye/internal/tui/styles.go


**Status:** DONE

**Files created:**
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/styles.go` — 11-field `Theme` struct + `DefaultTheme()`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/browser.go` — `Browser` struct with `NewBrowser`, `SetSize`, `SelectedIndex`, `SelectedFile`, `Move`, `View`, `renderTree`, `colorizeUnified`, `refreshViewport`
- `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/browser_test.go` — 2 tests

**Test count:** 2 tests, race-clean (`go test -race ./internal/tui/... -v`)

**Commit SHA:** `d3eec79`

**Unexpected items:**
- `github.com/charmbracelet/bubbles`, `lipgloss`, and `bubbletea` were not in `go.mod` yet (task description said "already in go.mod from Task 1" but they weren't). Added them via `go get`; they landed as `// indirect` in `go.mod` until the source files triggered them to resolve correctly. `go build ./...` and `go vet ./...` both clean.
- No `Init()`, `Update()`, or `RunBrowser` defined — those remain for Task 13.