You are implementing Task 13 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 12 (TUI scaffold + go.mod fix at `8b740bd`).
- Use absolute paths or `cd` into the worktree.
- **NEVER push.** **NEVER use `git add .` / `git add -A`.**

## Context

Task 12 built `Browser` as a non-tea struct with `View`, `Move`, `SelectedFile`, etc. Your job:
1. Make Browser implement `tea.Model` (add `Init()` and `Update(tea.Msg)`)
2. Create `app.go` exposing `RunBrowser(ctx, diff.Result) error`
3. Wire the TUI into `cmd diff` so it launches when stdout is a TTY and `--plain` is not set

You'll also add `golang.org/x/term` for TTY detection.

The bubbletea state model: `Update` returns `(tea.Model, tea.Cmd)`. The Model is a value (interface), so we return `b` (which is `*Browser`). The viewport sub-model handles scroll keys naturally — forward unhandled messages to it.

Strict TDD per the existing pattern.

## Task Description (Verbatim)

### Task 13: TUI — Browser as a `tea.Model` with key handling

**Files:**
- Create: `apps/birdseye/internal/tui/app.go`
- Modify: `apps/birdseye/internal/tui/browser.go` (implement `tea.Model`)
- Modify: `apps/birdseye/internal/tui/browser_test.go` (state transitions)
- Modify: `apps/birdseye/cmd/diff.go` (wire TTY launch)
- Modify: `apps/birdseye/go.mod` / `go.sum` (add `golang.org/x/term`)

- [ ] **Step 1: Append failing tests** to `apps/birdseye/internal/tui/browser_test.go`:

You may need to merge imports. If the import block currently only has `testing` and the local `diff` package, expand it to include `tea`:

```go
import (
	"testing"

	tea "github.com/charmbracelet/bubbletea"

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

Append:

```go
func TestBrowserMovesDownAndUp(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	m, _ := b.Update(tea.KeyMsg{Type: tea.KeyDown})
	b2 := m.(*Browser)
	if b2.SelectedIndex() != 1 {
		t.Errorf("after down: index = %d, want 1", b2.SelectedIndex())
	}
	m, _ = b2.Update(tea.KeyMsg{Type: tea.KeyUp})
	b3 := m.(*Browser)
	if b3.SelectedIndex() != 0 {
		t.Errorf("after up: index = %d, want 0", b3.SelectedIndex())
	}
}

func TestBrowserQuitMessage(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	_, cmd := b.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
	if cmd == nil {
		t.Fatal("expected tea.Quit cmd")
	}
	if got, want := cmd(), tea.Quit(); got != want {
		t.Errorf("cmd msg = %v, want %v", got, want)
	}
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/tui/... -run TestBrowserMoves -v`
Expected: `b.Update` undefined.

- [ ] **Step 3: Append `tea.Model` implementation** to `apps/birdseye/internal/tui/browser.go`

Add `tea` to the existing import block:

```go
import (
    "strings"

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

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

Append:

```go
// Init satisfies tea.Model. No initial cmd — browser is purely reactive.
func (b *Browser) Init() tea.Cmd { return nil }

// Update handles key presses and window-resize messages.
func (b *Browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		b.SetSize(msg.Width, msg.Height)
		return b, nil
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyDown:
			b.Move(1)
			return b, nil
		case tea.KeyUp:
			b.Move(-1)
			return b, nil
		case tea.KeyRunes:
			switch string(msg.Runes) {
			case "q":
				return b, tea.Quit
			case "j":
				b.Move(1)
			case "k":
				b.Move(-1)
			}
		}
	}
	// Forward to viewport for scroll handling.
	var cmd tea.Cmd
	b.viewport, cmd = b.viewport.Update(msg)
	return b, cmd
}
```

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

```go
package tui

import (
	"context"

	tea "github.com/charmbracelet/bubbletea"

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

// RunBrowser launches a full-screen bubbletea program rendering the
// supplied result. Returns when the user quits (q or Ctrl+C).
func RunBrowser(ctx context.Context, result diff.Result) error {
	b := NewBrowser(result)
	p := tea.NewProgram(b, tea.WithAltScreen(), tea.WithContext(ctx))
	_, err := p.Run()
	return err
}
```

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

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

- [ ] **Step 6: Wire TUI into `apps/birdseye/cmd/diff.go`**

Add to the imports of `diff.go`:

```go
import (
    // ... existing imports ...
    "golang.org/x/term"

    "github.com/immuta/operations/apps/birdseye/internal/tui"
)
```

Add to `apps/birdseye/go.mod`:

```
require (
    ...
    golang.org/x/term v0.27.0  // (or whatever the latest is)
)
```

Then `go mod tidy` to resolve.

In `runDiff`, replace the final line `return renderPlain(cmd.OutOrStdout(), result)` with:

```go
	if !o.Plain && isTTY(cmd.OutOrStdout()) {
		return tui.RunBrowser(ctx, result)
	}
	return renderPlain(cmd.OutOrStdout(), result)
}

// isTTY reports whether w is a *os.File backed by a terminal.
func isTTY(w io.Writer) bool {
	f, ok := w.(*os.File)
	if !ok {
		return false
	}
	return term.IsTerminal(int(f.Fd()))
}
```

(`isTTY` goes at file scope below `runDiff`.)

- [ ] **Step 7: Run all tests, expect PASS**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v`
Expected: All PASS — the existing `cmd` tests pass `--plain` and `t.TempDir()` so the TTY branch never runs in tests.

- [ ] **Step 8: Verify build**

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./...`
Expected: success.

- [ ] **Step 9: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/ && git commit -m "feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration"
```

Even though we said never `git add .`, here `git add apps/birdseye/` adds ONLY files under birdseye that have been modified — that's narrower than `-A` and safe. Verify with `git status` before committing that nothing unexpected is staged.

## Notes & Style

- The test for `tea.Quit` uses `cmd()` to invoke the command function and asserts the message equals `tea.Quit()`. Both `cmd()` and `tea.Quit()` return a `tea.Msg` of type `tea.QuitMsg{}`, so the equality holds.
- `bubbletea`'s `tea.WithContext` is the modern way to wire ctx cancellation. Older examples use `tea.WithSignalHandler` — don't use those.
- `bubbles/viewport.Model.Update` returns `(viewport.Model, tea.Cmd)` — the assignment `b.viewport, cmd = b.viewport.Update(msg)` correctly reassigns the embedded model.
- `bubbletea` is a direct dep now — go.mod should show it without `// indirect`.

## Code Organization

`browser.go` keeps Update/Init alongside the existing Browser methods. `app.go` is a tiny launcher (10-ish lines). `diff.go` gains a small TTY guard.

## Self-Review Checklist

- [ ] All TUI tests pass: `TestNewBrowserDefaults`, `TestBrowserViewContainsBothFiles`, `TestBrowserMovesDownAndUp`, `TestBrowserQuitMessage`
- [ ] All previous tests still pass
- [ ] `go build ./...` succeeds
- [ ] `go vet ./...` clean
- [ ] `golang.org/x/term` is a DIRECT dep in go.mod (no `// indirect`)
- [ ] `bubbletea` is a DIRECT dep in go.mod
- [ ] `isTTY` is a free function (not a method on `*Browser`)
- [ ] `runDiff` calls `tui.RunBrowser` ONLY when `!o.Plain && isTTY(...)` is true
- [ ] Commit message exactly: `feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files modified/created
- Test count + race-clean
- go.mod state for `bubbletea` / `term` (direct vs indirect)
- 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 13 following the TDD steps exactly. Let me start by reading the existing files.


1	package tui
2
3	import (
4		"strings"
5
6		"github.com/charmbracelet/bubbles/viewport"
7		"github.com/charmbracelet/lipgloss"
8
9		"github.com/immuta/operations/apps/birdseye/internal/diff"
10	)
11
12	// Browser is the multi-pane diff viewer: file tree on the left,
13	// rendered unified diff on the right, footer at the bottom.
14	type Browser struct {
15		result   diff.Result
16		theme    Theme
17		selected int
18		width    int
19		height   int
20		viewport viewport.Model
21	}
22
23	// NewBrowser builds a Browser from a pre-computed Result. SetSize must
24	// be called once before the first View() so the viewport sizes itself.
25	func NewBrowser(r diff.Result) *Browser {
26		vp := viewport.New(0, 0)
27		return &Browser{
28			result:   r,
29			theme:    DefaultTheme(),
30			viewport: vp,
31		}
32	}
33
34	// SetSize informs the browser of the terminal dimensions.
35	func (b *Browser) SetSize(w, h int) {
36		b.width, b.height = w, h
37		fileTreeW := 32
38		if w < 80 {
39			fileTreeW = 24
40		}
41		contentW := w - fileTreeW - 4
42		contentH := h - 2
43		if contentW < 10 {
44			contentW = 10
45		}
46		if contentH < 5 {
47			contentH = 5
48		}
49		b.viewport.Width = contentW
50		b.viewport.Height = contentH
51		b.refreshViewport()
52	}
53
54	// SelectedIndex returns the currently focused file diff index.
55	func (b *Browser) SelectedIndex() int { return b.selected }
56
57	// SelectedFile returns the FileDiff at the current selection. Falls
58	// back to a zero value when the result has no files.
59	func (b *Browser) SelectedFile() diff.FileDiff {
60		if len(b.result.Files) == 0 {
61			return diff.FileDiff{}
62		}
63		return b.result.Files[b.selected]
64	}
65
66	// Move adjusts the selection by delta, clamping to the valid range, and
67	// refreshes the viewport content.
68	func (b *Browser) Move(delta int) {
69		if len(b.result.Files) == 0 {
70			return
71		}
72		b.selected += delta
73		if b.selected < 0 {
74			b.selected = 0
75		}
76		if b.selected >= len(b.result.Files) {
77			b.selected = len(b.result.Files) - 1
78		}
79		b.refreshViewport()
80	}
81
82	func (b *Browser) refreshViewport() {
83		if len(b.result.Files) == 0 {
84			b.viewport.SetContent("no differences")
85			return
86		}
87		b.viewport.SetContent(b.colorizeUnified(b.SelectedFile().Unified))
88		b.viewport.GotoTop()
89	}
90
91	func (b *Browser) colorizeUnified(unified string) string {
92		var sb strings.Builder
93		for _, line := range strings.Split(unified, "\n") {
94			switch {
95			case strings.HasPrefix(line, "@@"):
96				sb.WriteString(b.theme.HunkHeader.Render(line))
97			case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
98				sb.WriteString(b.theme.AddedLine.Render(line))
99			case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
100				sb.WriteString(b.theme.RemovedLine.Render(line))
101			default:
102				sb.WriteString(line)
103			}
104			sb.WriteByte('\n')
105		}
106		return sb.String()
107	}
108
109	// View renders the full multi-pane layout.
110	func (b *Browser) View() string {
111		tree := b.renderTree()
112		right := b.theme.Viewport.Render(b.viewport.View())
113		body := lipgloss.JoinHorizontal(lipgloss.Top, tree, right)
114		footer := b.theme.Footer.Render("j/k or ↑/↓ move · q quit · esc back")
115		return lipgloss.JoinVertical(lipgloss.Left, body, footer)
116	}
117
118	func (b *Browser) renderTree() string {
119		var sb strings.Builder
120		for i, fd := range b.result.Files {
121			marker := " "
122			switch fd.Status {
123			case diff.StatusAdded:
124				marker = b.theme.StatusAdd.Render("+")
125			case diff.StatusRemoved:
126				marker = b.theme.StatusDel.Render("-")
127			case diff.StatusModified:
128				marker = b.theme.StatusMod.Render("~")
129			}
130			line := marker + " " + fd.Path
131			if i == b.selected {
132				line = b.theme.FileSelected.Render(line)
133			} else {
134				line = b.theme.FileTreeItem.Render(line)
135			}
136			sb.WriteString(line)
137			sb.WriteByte('\n')
138		}
139		if len(b.result.Files) == 0 {
140			sb.WriteString("(no files)")
141		}
142		return b.theme.FileTree.Render(sb.String())
143	}
144


1	package tui
2
3	import (
4		"testing"
5
6		"github.com/immuta/operations/apps/birdseye/internal/diff"
7	)
8
9	func sampleResult() diff.Result {
10		return diff.Result{Files: []diff.FileDiff{
11			{Path: "a.tf", Status: diff.StatusModified, Unified: "@@\n-x\n+y\n", Adds: 1, Dels: 1},
12			{Path: "b.tf", Status: diff.StatusAdded, Unified: "+++ b.tf\n+new\n", Adds: 1},
13		}}
14	}
15
16	func TestNewBrowserDefaults(t *testing.T) {
17		b := NewBrowser(sampleResult())
18		if b.SelectedIndex() != 0 {
19			t.Errorf("default selected index = %d, want 0", b.SelectedIndex())
20		}
21		if b.SelectedFile().Path != "a.tf" {
22			t.Errorf("first selected = %s", b.SelectedFile().Path)
23		}
24	}
25
26	func TestBrowserViewContainsBothFiles(t *testing.T) {
27		b := NewBrowser(sampleResult())
28		b.SetSize(120, 30)
29		view := b.View()
30		for _, want := range []string{"a.tf", "b.tf"} {
31			if !contains(view, want) {
32				t.Errorf("view missing %q\n%s", want, view)
33			}
34		}
35	}
36
37	func contains(s, sub string) bool {
38		for i := 0; i+len(sub) <= len(s); i++ {
39			if s[i:i+len(sub)] == sub {
40				return true
41			}
42		}
43		return false
44	}
45


1	package cmd
2
3	import (
4		"context"
5		"fmt"
6		"io"
7		"net/http"
8		"os"
9		"path/filepath"
10		"strings"
11		"time"
12
13		"github.com/spf13/cobra"
14
15		"github.com/immuta/operations/apps/birdseye/internal/archive"
16		"github.com/immuta/operations/apps/birdseye/internal/diff"
17		"github.com/immuta/operations/apps/birdseye/internal/registry"
18		"github.com/immuta/operations/services/crow/pkg/api"
19		"github.com/immuta/operations/services/crow/pkg/client"
20	)
21
22	// diffOpts holds the resolved options for `birdseye diff`.
23	type diffOpts struct {
24		Semantic bool
25		Plain    bool
26		CacheDir string
27	}
28
29	const maxArchiveBytes = 50 << 20 // 50 MiB safety cap on uncompressed size
30
31	func newDiffCmd(g *GlobalFlags) *cobra.Command {
32		o := &diffOpts{}
33		c := &cobra.Command{
34			Use:   "diff [module] [versionA] [versionB]",
35			Short: "Diff two versions of a Crow module",
36			Long: "Diff two versions of a module hosted in Crow.\n\n" +
37				"If module/versions are omitted, the interactive TUI launches\n" +
38				"(see picker task). With --plain, prints unified diff to stdout.",
39			Args:          cobra.MaximumNArgs(3),
40			SilenceUsage:  true,
41			SilenceErrors: true,
42			RunE: func(cmd *cobra.Command, args []string) error {
43				return runDiff(cmd.Context(), cmd, args, g, o)
44			},
45		}
46		c.Flags().BoolVar(&o.Semantic, "semantic", false, "include HCL-aware semantic diff alongside textual")
47		c.Flags().BoolVar(&o.Plain, "plain", false, "force plain (non-TUI) output")
48		c.Flags().BoolVar(&o.Plain, "no-tui", false, "alias for --plain")
49		c.Flags().StringVar(&o.CacheDir, "cache-dir", defaultCacheDir(), "module tarball cache directory")
50		return c
51	}
52
53	func defaultCacheDir() string {
54		if home, err := os.UserCacheDir(); err == nil {
55			return filepath.Join(home, "birdseye")
56		}
57		return ".birdseye-cache"
58	}
59
60	func newHTTPClient(d time.Duration) *http.Client {
61		return &http.Client{Timeout: d}
62	}
63
64	func parseModuleAddress(s string) (api.ModuleIdentifier, error) {
65		parts := strings.Split(s, "/")
66		if len(parts) != 3 {
67			return api.ModuleIdentifier{}, fmt.Errorf("%w: module address must be namespace/name/provider, got %q", ErrUsage, s)
68		}
69		id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
70		if err := id.Validate(); err != nil {
71			return api.ModuleIdentifier{}, fmt.Errorf("%w: %v", ErrUsage, err)
72		}
73		return id, nil
74	}
75
76	func runDiff(ctx context.Context, cmd *cobra.Command, args []string, g *GlobalFlags, o *diffOpts) error {
77		if len(args) == 0 {
78			// Interactive flow lives in Task 15; for now fall through to error.
79			return fmt.Errorf("%w: interactive mode not yet implemented; provide <module> <versionA> <versionB>", ErrUsage)
80		}
81		if len(args) != 3 {
82			return fmt.Errorf("%w: diff requires <module> <versionA> <versionB>", ErrUsage)
83		}
84		id, err := parseModuleAddress(args[0])
85		if err != nil {
86			return err
87		}
88		if err := api.ValidateVersion(args[1]); err != nil {
89			return fmt.Errorf("%w: %v", ErrUsage, err)
90		}
91		if err := api.ValidateVersion(args[2]); err != nil {
92			return fmt.Errorf("%w: %v", ErrUsage, err)
93		}
94		modA := api.Module{ModuleIdentifier: id, Version: args[1]}
95		modB := api.Module{ModuleIdentifier: id, Version: args[2]}
96
97		cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
98		cache, err := registry.NewDiskCache(o.CacheDir)
99		if err != nil {
100			return fmt.Errorf("cache init: %w", err)
101		}
102		reg := registry.New(cli, cache)
103
104		aBytes, bBytes, err := reg.FetchPair(ctx, modA, modB)
105		if err != nil {
106			return err
107		}
108
109		modAExtracted, err := archive.Extract(aBytes, maxArchiveBytes)
110		if err != nil {
111			return fmt.Errorf("%w: %v", ErrArchive, err)
112		}
113		modBExtracted, err := archive.Extract(bBytes, maxArchiveBytes)
114		if err != nil {
115			return fmt.Errorf("%w: %v", ErrArchive, err)
116		}
117
118		result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
119		// Semantic mode wired in Task 17.
120		// TUI mode wired in Task 13.
121
122		return renderPlain(cmd.OutOrStdout(), result)
123	}
124
125	// renderPlain writes the diff result to w in unified-diff format.
126	// "no differences" is emitted when there are zero files in the result.
127	func renderPlain(w io.Writer, r diff.Result) error {
128		if len(r.Files) == 0 {
129			_, err := fmt.Fprintln(w, "no differences")
130			return err
131		}
132		for i, fd := range r.Files {
133			if i > 0 {
134				_, _ = fmt.Fprintln(w)
135			}
136			_, _ = fmt.Fprintf(w, "=== %s (%s) +%d -%d\n", fd.Path, fd.Status, fd.Adds, fd.Dels)
137			_, _ = fmt.Fprint(w, fd.Unified)
138		}
139		return nil
140	}
141


1	module github.com/immuta/operations/apps/birdseye
2
3	go 1.25.5
4
5	require (
6		github.com/charmbracelet/bubbles v1.0.0
7		github.com/charmbracelet/lipgloss v1.1.0
8		github.com/hexops/gotextdiff v1.0.3
9		github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a
10		github.com/spf13/cobra v1.9.1
11		golang.org/x/sync v0.20.0
12	)
13
14	require (
15		github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
16		github.com/charmbracelet/bubbletea v1.3.10 // indirect
17		github.com/charmbracelet/colorprofile v0.4.1 // indirect
18		github.com/charmbracelet/x/ansi v0.11.6 // indirect
19		github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
20		github.com/charmbracelet/x/term v0.2.2 // indirect
21		github.com/clipperhouse/displaywidth v0.9.0 // indirect
22		github.com/clipperhouse/stringish v0.1.1 // indirect
23		github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
24		github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
25		github.com/inconshreveable/mousetrap v1.1.0 // indirect
26		github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
27		github.com/mattn/go-isatty v0.0.20 // indirect
28		github.com/mattn/go-localereader v0.0.1 // indirect
29		github.com/mattn/go-runewidth v0.0.19 // indirect
30		github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
31		github.com/muesli/cancelreader v0.2.2 // indirect
32		github.com/muesli/termenv v0.16.0 // indirect
33		github.com/rivo/uniseg v0.4.7 // indirect
34		github.com/spf13/pflag v1.0.6 // indirect
35		github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
36		golang.org/x/sys v0.41.0 // indirect
37		golang.org/x/text v0.34.0 // indirect
38	)
39
40	replace github.com/immuta/operations/services/crow => ../../services/crow
41
42	replace github.com/immuta/operations/libs/clog => ../../libs/clog
43


Good. Now I have a clear picture. Let me execute the steps.

**Step 1: Append failing tests to browser_test.go**


Edit

package tui

import (
	"testing"

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

package tui

import (
	"testing"

	tea "github.com/charmbracelet/bubbletea"

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


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


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
}

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
}

func TestBrowserMovesDownAndUp(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	m, _ := b.Update(tea.KeyMsg{Type: tea.KeyDown})
	b2 := m.(*Browser)
	if b2.SelectedIndex() != 1 {
		t.Errorf("after down: index = %d, want 1", b2.SelectedIndex())
	}
	m, _ = b2.Update(tea.KeyMsg{Type: tea.KeyUp})
	b3 := m.(*Browser)
	if b3.SelectedIndex() != 0 {
		t.Errorf("after up: index = %d, want 0", b3.SelectedIndex())
	}
}

func TestBrowserQuitMessage(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	_, cmd := b.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
	if cmd == nil {
		t.Fatal("expected tea.Quit cmd")
	}
	if got, want := cmd(), tea.Quit(); got != want {
		t.Errorf("cmd msg = %v, want %v", got, want)
	}
}


**Step 2: Run, expect FAIL**


Bash

cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/tui/... -run TestBrowserMoves -v 2>&1 | head -30

Run failing test to confirm Update undefined


# github.com/immuta/operations/apps/birdseye/internal/tui [github.com/immuta/operations/apps/birdseye/internal/tui.test]
internal/tui/browser_test.go:51:12: b.Update undefined (type *Browser has no field or method Update)
internal/tui/browser_test.go:66:14: b.Update undefined (type *Browser has no field or method Update)
FAIL	github.com/immuta/operations/apps/birdseye/internal/tui [build failed]
FAIL


Expected failure confirmed. **Step 3: Implement tea.Model on Browser**


Edit

import (
	"strings"

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

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

import (
	"strings"

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

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


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


// View renders the full multi-pane layout.

// Init satisfies tea.Model. No initial cmd — browser is purely reactive.
func (b *Browser) Init() tea.Cmd { return nil }

// Update handles key presses and window-resize messages.
func (b *Browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		b.SetSize(msg.Width, msg.Height)
		return b, nil
	case tea.KeyMsg:
		switch msg.Type {
		case tea.KeyDown:
			b.Move(1)
			return b, nil
		case tea.KeyUp:
			b.Move(-1)
			return b, nil
		case tea.KeyRunes:
			switch string(msg.Runes) {
			case "q":
				return b, tea.Quit
			case "j":
				b.Move(1)
			case "k":
				b.Move(-1)
			}
		}
	}
	// Forward to viewport for scroll handling.
	var cmd tea.Cmd
	b.viewport, cmd = b.viewport.Update(msg)
	return b, cmd
}

// View renders the full multi-pane layout.


**Step 4: Create app.go**


Write

package tui

import (
	"context"

	tea "github.com/charmbracelet/bubbletea"

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

// RunBrowser launches a full-screen bubbletea program rendering the
// supplied result. Returns when the user quits (q or Ctrl+C).
func RunBrowser(ctx context.Context, result diff.Result) error {
	b := NewBrowser(result)
	p := tea.NewProgram(b, tea.WithAltScreen(), tea.WithContext(ctx))
	_, err := p.Run()
	return err
}


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


**Step 5: Run TUI tests, expect PASS**


Bash

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

Run all TUI tests with race detector


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


All 4 TUI tests pass. **Step 6: Wire TUI into diff.go**

First, add `golang.org/x/term` to go.mod as a direct dependency:


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go get golang.org/x/term 2>&1

Add golang.org/x/term as a direct dependency


go: downloading golang.org/x/term v0.43.0
go: downloading golang.org/x/sys v0.44.0
go: upgraded golang.org/x/sys v0.41.0 => v0.44.0
go: added golang.org/x/term v0.43.0


Now also promote `bubbletea` from indirect to direct since it's used directly in `app.go`:


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go get github.com/charmbracelet/bubbletea 2>&1

Promote bubbletea to direct dependency


(Bash completed with no output)


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

Run go mod tidy to clean up dependencies


grep -E "(bubbletea|x/term)" /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/go.mod

Check direct/indirect status of bubbletea and x/term


	github.com/charmbracelet/bubbletea v1.3.10
	github.com/charmbracelet/x/term v0.2.2 // indirect


Error: File does not exist. Note: your current working directory is /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye.


Good - `bubbletea` is now direct (no `// indirect`). `golang.org/x/term` should also be there — let me check:


Check golang.org/x/term in go.mod


Hmm, `go mod tidy` may have removed it if it's not imported yet. Let me wire `diff.go` first, then tidy again:


Edit

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
	"github.com/immuta/operations/apps/birdseye/internal/diff"
	"github.com/immuta/operations/apps/birdseye/internal/registry"
	"github.com/immuta/operations/services/crow/pkg/api"
	"github.com/immuta/operations/services/crow/pkg/client"
)

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"
	"golang.org/x/term"

	"github.com/immuta/operations/apps/birdseye/internal/archive"
	"github.com/immuta/operations/apps/birdseye/internal/diff"
	"github.com/immuta/operations/apps/birdseye/internal/registry"
	"github.com/immuta/operations/apps/birdseye/internal/tui"
	"github.com/immuta/operations/services/crow/pkg/api"
	"github.com/immuta/operations/services/crow/pkg/client"
)


The file /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/cmd/diff.go has been updated successfully. (file state is current in your context — no need to Read it back)


	result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
	// Semantic mode wired in Task 17.
	// TUI mode wired in Task 13.

	return renderPlain(cmd.OutOrStdout(), result)
}

	result := diff.Result{Files: diff.Textual(modAExtracted, modBExtracted)}
	// Semantic mode wired in Task 17.

	if !o.Plain && isTTY(cmd.OutOrStdout()) {
		return tui.RunBrowser(ctx, result)
	}
	return renderPlain(cmd.OutOrStdout(), result)
}

// isTTY reports whether w is a *os.File backed by a terminal.
func isTTY(w io.Writer) bool {
	f, ok := w.(*os.File)
	if !ok {
		return false
	}
	return term.IsTerminal(int(f.Fd()))
}


Now run `go mod tidy` again to pick up the new import:


Bash

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

Run go mod tidy after adding term import


go: finding module for package golang.org/x/term
go: found golang.org/x/term in golang.org/x/term v0.43.0


Verify both are direct deps in go.mod


	github.com/charmbracelet/bubbletea v1.3.10
	golang.org/x/term v0.43.0


Both are direct (no `// indirect`). **Step 7: Run all tests**


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

Run all tests with race detector


?   	github.com/immuta/operations/apps/birdseye	[no test files]
=== RUN   TestDiffPlainOutput
--- PASS: TestDiffPlainOutput (0.01s)
=== RUN   TestDiffIdenticalVersionsNoChanges
--- PASS: TestDiffIdenticalVersionsNoChanges (0.01s)
=== RUN   TestDiffBadModuleAddress
--- PASS: TestDiffBadModuleAddress (0.00s)
=== RUN   TestExitCode
=== RUN   TestExitCode/nil
=== RUN   TestExitCode/not-found
=== RUN   TestExitCode/wrapped-not-found
=== RUN   TestExitCode/context-cancelled
=== RUN   TestExitCode/context-deadline
=== RUN   TestExitCode/generic
--- PASS: TestExitCode (0.00s)
    --- PASS: TestExitCode/nil (0.00s)
    --- PASS: TestExitCode/not-found (0.00s)
    --- PASS: TestExitCode/wrapped-not-found (0.00s)
    --- PASS: TestExitCode/context-cancelled (0.00s)
    --- PASS: TestExitCode/context-deadline (0.00s)
    --- PASS: TestExitCode/generic (0.00s)
=== RUN   TestNewRootCmdHelp
--- PASS: TestNewRootCmdHelp (0.00s)
=== RUN   TestNewRootCmdDefaults
--- PASS: TestNewRootCmdDefaults (0.00s)
=== RUN   TestVersionsCmd
--- PASS: TestVersionsCmd (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/cmd	1.760s
=== RUN   TestExtractHappyPath
--- PASS: TestExtractHappyPath (0.00s)
=== RUN   TestExtractEmptyArchive
--- PASS: TestExtractEmptyArchive (0.00s)
=== RUN   TestExtractMalformedGzip
--- PASS: TestExtractMalformedGzip (0.00s)
=== RUN   TestExtractZipSlip
=== RUN   TestExtractZipSlip/../evil.tf
=== RUN   TestExtractZipSlip/./../../etc/passwd
=== RUN   TestExtractZipSlip/a/../../etc/passwd
--- PASS: TestExtractZipSlip (0.00s)
    --- PASS: TestExtractZipSlip/../evil.tf (0.00s)
    --- PASS: TestExtractZipSlip/./../../etc/passwd (0.00s)
    --- PASS: TestExtractZipSlip/a/../../etc/passwd (0.00s)
=== RUN   TestExtractAbsolutePath
--- PASS: TestExtractAbsolutePath (0.00s)
=== RUN   TestExtractTooLarge
--- PASS: TestExtractTooLarge (0.00s)
=== RUN   TestExtractIgnoresSymlinks
--- PASS: TestExtractIgnoresSymlinks (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/archive	(cached)
=== RUN   TestTextualAdded
--- PASS: TestTextualAdded (0.00s)
=== RUN   TestTextualRemoved
--- PASS: TestTextualRemoved (0.00s)
=== RUN   TestTextualModified
--- PASS: TestTextualModified (0.00s)
=== RUN   TestTextualIdenticalReturnsEmpty
--- PASS: TestTextualIdenticalReturnsEmpty (0.00s)
=== RUN   TestTextualSortedByPath
--- PASS: TestTextualSortedByPath (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/diff	(cached)
=== RUN   TestMemCacheRoundTrip
--- PASS: TestMemCacheRoundTrip (0.00s)
=== RUN   TestDiskCacheMissThenHit
--- PASS: TestDiskCacheMissThenHit (0.00s)
=== RUN   TestDiskCacheSurvivesNewInstance
--- PASS: TestDiskCacheSurvivesNewInstance (0.00s)
=== RUN   TestRegistryFetchCacheMiss
--- PASS: TestRegistryFetchCacheMiss (0.00s)
=== RUN   TestRegistryFetchCacheHitSkipsNetwork
--- PASS: TestRegistryFetchCacheHitSkipsNetwork (0.00s)
=== RUN   TestRegistryFetchNotFound
--- PASS: TestRegistryFetchNotFound (0.00s)
=== RUN   TestRegistryFetchPair
--- PASS: TestRegistryFetchPair (0.00s)
=== RUN   TestRegistryFetchPairOneMissing
--- PASS: TestRegistryFetchPairOneMissing (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/registry	(cached)
=== RUN   TestNewBrowserDefaults
--- PASS: TestNewBrowserDefaults (0.00s)
=== RUN   TestBrowserViewContainsBothFiles
--- PASS: TestBrowserViewContainsBothFiles (0.00s)
=== RUN   TestBrowserMovesDownAndUp
--- PASS: TestBrowserMovesDownAndUp (0.00s)
=== RUN   TestBrowserQuitMessage
--- PASS: TestBrowserQuitMessage (0.00s)
PASS
ok  	github.com/immuta/operations/apps/birdseye/internal/tui	1.853s


All tests pass. **Step 8: Verify build**


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

Build and vet all packages


Clean build and vet. **Step 9: Commit**


Check git status before staging


On branch worktree-birdseye
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   apps/birdseye/cmd/diff.go
	modified:   apps/birdseye/go.mod
	modified:   apps/birdseye/go.sum
	modified:   apps/birdseye/internal/tui/browser.go
	modified:   apps/birdseye/internal/tui/browser_test.go

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	.claude/mind.mv2.lock
	apps/birdseye/internal/tui/app.go

no changes added to commit (use "git add" and/or "git commit -a")


Good — only `apps/birdseye/` changes plus the new `app.go`. The `.claude/mind.mv2.lock` is unrelated and won't be staged.


git -C /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye add apps/birdseye/ && git -C /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye status

Stage birdseye app files and verify staging


On branch worktree-birdseye
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
	modified:   apps/birdseye/cmd/diff.go
	modified:   apps/birdseye/go.mod
	modified:   apps/birdseye/go.sum
	new file:   apps/birdseye/internal/tui/app.go
	modified:   apps/birdseye/internal/tui/browser.go
	modified:   apps/birdseye/internal/tui/browser_test.go

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	.claude/mind.mv2.lock


Staged exactly the right files. Nothing unexpected.


git -C /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye commit -m "$(cat <<'EOF'
feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)"

Create commit with exact message from task spec


[worktree-birdseye 89436d6] feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration
 6 files changed, 103 insertions(+), 5 deletions(-)
 create mode 100644 apps/birdseye/internal/tui/app.go


Done.

---

**Status:** DONE

**Files modified/created:**
- `apps/birdseye/internal/tui/browser.go` — added `Init()` and `Update()` implementing `tea.Model`; added `tea` import
- `apps/birdseye/internal/tui/browser_test.go` — expanded imports to include `tea`; appended `TestBrowserMovesDownAndUp` and `TestBrowserQuitMessage`
- `apps/birdseye/internal/tui/app.go` — created; `RunBrowser(ctx, diff.Result) error` launcher
- `apps/birdseye/cmd/diff.go` — added `golang.org/x/term` + `tui` imports; replaced final `renderPlain` line with TTY guard; added `isTTY` free function
- `apps/birdseye/go.mod` / `go.sum` — promoted `bubbletea` to direct; added `golang.org/x/term v0.43.0` as direct

**Test count:** 25 tests across 5 packages, all PASS, race-clean.

**go.mod state:**
- `github.com/charmbracelet/bubbletea v1.3.10` — direct (no `// indirect`)
- `golang.org/x/term v0.43.0` — direct (no `// indirect`)

**Commit SHA:** `89436d6`

**Nothing unexpected** — `go mod tidy` after wiring `diff.go` correctly resolved `golang.org/x/term` and it landed at `v0.43.0` (newer than the `v0.27.0` suggested in the plan, which is fine).