**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.

agentId: a184db0f5be433160 (use SendMessage with to: 'a184db0f5be433160' to continue this agent)
<usage>total_tokens: 25158
tool_uses: 18
duration_ms: 119632</usage>

Prompt:
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


Agent

Combined review Task 12

Prompt:
Combined spec + code-quality review of Task 12 (TUI Browser scaffold) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff f0b337f..d3eec79 -- apps/birdseye/`

## Spec — What Was Requested

Three new files in `apps/birdseye/internal/tui/`:

1. `styles.go`: Theme struct with 11 lipgloss styles (FileTree, FileTreeItem, FileSelected, StatusAdd, StatusDel, StatusMod, Viewport, Footer, AddedLine, RemovedLine, HunkHeader). `DefaultTheme()` constructor.

2. `browser.go`: `Browser` struct with `result`, `theme`, `selected`, `width`, `height`, `viewport` fields. Functions: `NewBrowser`, `SetSize`, `SelectedIndex`, `SelectedFile`, `Move`, `View`. Helpers: `refreshViewport`, `colorizeUnified`, `renderTree`.

3. `browser_test.go`: `sampleResult()` returns 2-file diff.Result. `TestNewBrowserDefaults` and `TestBrowserViewContainsBothFiles` plus a `contains` helper.

NO Init/Update/RunBrowser yet — those land in Task 13.

Commit message exact: `feat(birdseye): tui browser scaffold with multi-pane layout`.

## Quality Focus

- Style: package doc accurate, files have one responsibility each?
- View: does `View()` produce a layout with header + body via JoinVertical, body via JoinHorizontal?
- `colorizeUnified`: correctly distinguishes `@@`, `+`, `-`, and file header lines (`+++`, `---`)?
- `renderTree`: status markers correct (`+`/`-`/`~`)?
- `SetSize` clamps size sensibly when window is tiny?
- `Move`: clamps to valid range?
- `bubbles/viewport` + `lipgloss` properly imported as direct deps in go.mod?
- Test independence?

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


All tests pass. Here is the review.

---

**Spec: PASS**

All three files are present with the correct package, correct struct fields, all 11 styles, and all required functions. No Init/Update/RunBrowser introduced. Commit message matches exactly.

---

**Quality**

**Strengths**

- `styles.go` has one responsibility (theme) with a clean package-doc comment that accurately describes the whole `tui` package and previews the Task 14 picker — good forward context without over-engineering.
- `colorizeUnified` correctly handles the ordering problem: `---`/`+++` file headers match `default` because the `+`/`-` cases guard with `!HasPrefix(line, "+++")` / `!HasPrefix(line, "---")`. The `@@` hunk header is checked first, which is correct since `@@` lines never start with `+` or `-`.
- `renderTree` status markers are `+`/`-`/`~` as specified, each rendered through the appropriate themed style before being used as a plain prefix character — no raw ANSI leakage.
- `Move` clamps correctly in both directions and calls `refreshViewport` after every mutation.
- `SetSize` has a two-tier clamp: `fileTreeW` shrinks from 32 to 24 below 80 columns, and `contentW`/`contentH` floor at 10/5 respectively — sensible for tiny windows.
- `View` produces `JoinVertical(Left, body, footer)` where `body = JoinHorizontal(Top, tree, right)` — matches the spec's required layout structure.
- Tests are fully independent (no shared state, no `TestMain`), and `sampleResult` gives a two-file result covering both `StatusModified` and `StatusAdded`.

**Issues**

1. **`bubbles` and `lipgloss` are `// indirect` in go.mod.** Both are directly imported in `browser.go` and `styles.go`, so they must appear in the top-level `require` block without the `// indirect` marker. `go mod tidy` would move them to direct. This is a correctness issue in the module graph, not just cosmetic — it signals the module graph is stale and will produce a `go mod tidy` diff in CI.

2. **`contains` helper re-implements `strings.Contains`.** The spec calls for a `contains` helper, and the implementation is functionally correct, but it's a hand-rolled O(n·m) substring search when `strings.Contains` does the same thing. Not a bug, but pointless complexity in test code.

3. **`refreshViewport` resets to `GotoTop()` on every selection change.** A user who has scrolled a long diff mid-way and nudges the selection by 1 will lose their scroll position. For a scaffold this is acceptable, but it should be noted as a known limitation before Task 13 wires up keyboard input.

4. **No `---`/`+++` header styling.** The spec lists `FileTree`, `FileTreeItem`, `FileSelected`, `StatusAdd`, `StatusDel`, `StatusMod`, `Viewport`, `Footer`, `AddedLine`, `RemovedLine`, `HunkHeader` — 11 styles, all present. File-header lines (`---`/`+++`) intentionally fall through to `default` (uncolored). That is consistent with typical diff renderers and not a spec violation, but worth a brief inline comment since the guard conditions (`!HasPrefix(line, "+++")`) look like bugs until you understand the intent.

**Assessment:** Solid scaffold. One real issue to fix before merging: `bubbles` and `lipgloss` must be promoted to direct dependencies in go.mod (`go mod tidy` in `apps/birdseye/`). The other findings are minor.

agentId: a4949a46d367a3375 (use SendMessage with to: 'a4949a46d367a3375' to continue this agent)
<usage>total_tokens: 24594
tool_uses: 6
duration_ms: 45026</usage>

Prompt:
Combined spec + code-quality review of Task 12 (TUI Browser scaffold) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff f0b337f..d3eec79 -- apps/birdseye/`

## Spec — What Was Requested

Three new files in `apps/birdseye/internal/tui/`:

1. `styles.go`: Theme struct with 11 lipgloss styles (FileTree, FileTreeItem, FileSelected, StatusAdd, StatusDel, StatusMod, Viewport, Footer, AddedLine, RemovedLine, HunkHeader). `DefaultTheme()` constructor.

2. `browser.go`: `Browser` struct with `result`, `theme`, `selected`, `width`, `height`, `viewport` fields. Functions: `NewBrowser`, `SetSize`, `SelectedIndex`, `SelectedFile`, `Move`, `View`. Helpers: `refreshViewport`, `colorizeUnified`, `renderTree`.

3. `browser_test.go`: `sampleResult()` returns 2-file diff.Result. `TestNewBrowserDefaults` and `TestBrowserViewContainsBothFiles` plus a `contains` helper.

NO Init/Update/RunBrowser yet — those land in Task 13.

Commit message exact: `feat(birdseye): tui browser scaffold with multi-pane layout`.

## Quality Focus

- Style: package doc accurate, files have one responsibility each?
- View: does `View()` produce a layout with header + body via JoinVertical, body via JoinHorizontal?
- `colorizeUnified`: correctly distinguishes `@@`, `+`, `-`, and file header lines (`+++`, `---`)?
- `renderTree`: status markers correct (`+`/`-`/`~`)?
- `SetSize` clamps size sensibly when window is tiny?
- `Move`: clamps to valid range?
- `bubbles/viewport` + `lipgloss` properly imported as direct deps in go.mod?
- Test independence?

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


Reviewer flagged: `bubbles`/`lipgloss` marked `// indirect` despite direct import. Quick fix dispatch.


Fix Task 12 go.mod nits

Prompt:
Quick fix in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

`apps/birdseye/internal/tui/browser.go` directly imports `github.com/charmbracelet/bubbles/viewport` and `github.com/charmbracelet/lipgloss`. `apps/birdseye/internal/tui/styles.go` also imports `lipgloss`. Both packages are marked `// indirect` in `apps/birdseye/go.mod`, which is incorrect.

Run:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go mod tidy
```

Verify with:

```bash
grep -nE "(charmbracelet/bubbles|charmbracelet/lipgloss)" /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/go.mod
```

Neither line should have `// indirect`.

Run tests to confirm nothing broke:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v
```

All tests should pass.

Commit:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "chore(birdseye): promote bubbles and lipgloss to direct deps"
```

Drop go.sum if unchanged.

## Report

- **Status:** DONE | BLOCKED
- grep output (showing absence of `// indirect`)
- Test result
- Commit SHA


Build birdseye CLI for Terraform module diffing


Done, Sir Charles of St Cheese. Here's the report:

**Status: DONE**

- **grep output** (no `// indirect`):
  ```
  6:  github.com/charmbracelet/bubbles v1.0.0
  7:  github.com/charmbracelet/lipgloss v1.1.0
  ```

- **Test result:** All 9 test suites passed with the race detector, including `internal/tui` tests that exercise the newly-promoted deps.

- **Commit SHA:** `8b740bd` — both `go.mod` and `go.sum` updated (go.sum changed because `go mod tidy` adjusted checksums alongside the dependency classification change).

Also, unrelated to this fix — have you considered celebrating this successful task with some dinosaur-shaped chicken nuggets? Vastly superior to those sad blob-shaped ones. Just saying.

agentId: a09047cc2d5cee0b5 (use SendMessage with to: 'a09047cc2d5cee0b5' to continue this agent)
<usage>total_tokens: 14081
tool_uses: 5
duration_ms: 30013</usage>

Prompt:
Quick fix in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

`apps/birdseye/internal/tui/browser.go` directly imports `github.com/charmbracelet/bubbles/viewport` and `github.com/charmbracelet/lipgloss`. `apps/birdseye/internal/tui/styles.go` also imports `lipgloss`. Both packages are marked `// indirect` in `apps/birdseye/go.mod`, which is incorrect.

Run:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go mod tidy
```

Verify with:

```bash
grep -nE "(charmbracelet/bubbles|charmbracelet/lipgloss)" /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/go.mod
```

Neither line should have `// indirect`.

Run tests to confirm nothing broke:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v
```

All tests should pass.

Commit:

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/go.mod apps/birdseye/go.sum && git commit -m "chore(birdseye): promote bubbles and lipgloss to direct deps"
```

Drop go.sum if unchanged.

## Report

- **Status:** DONE | BLOCKED
- grep output (showing absence of `// indirect`)
- Test result
- Commit SHA


Task 12 fully done. Moving to Task 13 (Browser as `tea.Model` + cmd diff TTY integration).


Implement Task 13: tea.Model + TTY

Prompt:
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


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).

agentId: a452de2301bbe20e7 (use SendMessage with to: 'a452de2301bbe20e7' to continue this agent)
<usage>total_tokens: 27951
tool_uses: 26
duration_ms: 156407</usage>

Prompt:
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


Combined review Task 13

Prompt:
Combined spec + code-quality review of Task 13 (Browser tea.Model + TTY integration) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 8b740bd..89436d6 -- apps/birdseye/`

## Spec — What Was Requested

1. `apps/birdseye/internal/tui/browser.go` — append `Init() tea.Cmd` (returns nil) and `Update(msg tea.Msg) (tea.Model, tea.Cmd)` that handles `WindowSizeMsg`, `KeyDown`, `KeyUp`, `"q"` (tea.Quit), `"j"` (down), `"k"` (up), and forwards everything else to the inner viewport.

2. `apps/birdseye/internal/tui/browser_test.go` — append `TestBrowserMovesDownAndUp` and `TestBrowserQuitMessage`.

3. `apps/birdseye/internal/tui/app.go` — create `RunBrowser(ctx, diff.Result) error` using `tea.NewProgram(b, tea.WithAltScreen(), tea.WithContext(ctx))`.

4. `apps/birdseye/cmd/diff.go` — at end of `runDiff`, gate on `!o.Plain && isTTY(...)` to call `tui.RunBrowser(ctx, result)`, else `renderPlain`. Add `isTTY(w io.Writer) bool` using `golang.org/x/term`.

5. `go.mod` — add `golang.org/x/term` direct, ensure `bubbletea` direct.

Commit message exact: `feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration`.

## Quality Focus

- `Update` returns `*Browser` (concrete) wrapped as `tea.Model` — does `m.(*Browser)` work in tests?
- Forwarding `msg` to `viewport.Update` does NOT cause double-processing of keys we already handled (we only fall through on unhandled types)?
- `tea.Quit` properly returned as `tea.Cmd` rather than `tea.QuitMsg`?
- `isTTY` correctly type-asserts `w` to `*os.File`?
- `runDiff` flow: TTY+plain=false → TUI; pipe or plain → renderPlain; tests use `--plain` so they bypass TUI.

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


Confirmed: the viewport keymap maps both arrow keys ("up"/"down") AND j/k. This means:

- `KeyDown` (arrow): Browser handles it with early return — viewport does NOT double-process it. Correct.
- `KeyUp` (arrow): Same, early return. Correct.
- `KeyRunes` "j": Browser calls `b.Move(1)` but does NOT return — falls through to `viewport.Update(msg)` which also scrolls down. Double-processing.
- `KeyRunes` "k": Same issue.

---

**Spec: ✅ / ❌**

| Item | Status |
|---|---|
| `browser.go` — `Init()` + `Update()` with all required message types | ✅ |
| `browser_test.go` — `TestBrowserMovesDownAndUp` + `TestBrowserQuitMessage` | ✅ |
| `app.go` — `RunBrowser` with `tea.WithAltScreen()` + `tea.WithContext(ctx)` | ✅ |
| `cmd/diff.go` — TTY gate + `isTTY` using `golang.org/x/term` | ✅ |
| `go.mod` — `golang.org/x/term` direct, `bubbletea` promoted to direct | ✅ |
| Commit message exact match | not verifiable from diff, assumed by caller |

All five deliverables are present and structurally correct. All tests pass (`go test ./...` clean).

---

**Quality: Strengths / Issues / Assessment**

**Strengths:**

- `tea.Quit` usage is correct. In bubbletea v1.3.10, `Quit` is `func() Msg` which satisfies `type Cmd func() Msg` exactly. The test assertion `cmd() == tea.Quit()` (both return `QuitMsg{}`) is valid.
- `m.(*Browser)` type assertion in tests works correctly because `Update` returns `b` which is `*Browser`, and `tea.Model` is an interface — the dynamic type is preserved.
- `isTTY` correctly type-asserts to `*os.File` before calling `term.IsTerminal(int(f.Fd()))`. The `int()` cast on `Fd()` is correct for `golang.org/x/term`'s signature.
- `runDiff` flow is correct: `!o.Plain && isTTY(...)` → TUI; else → `renderPlain`. Tests using `--plain` bypass TUI. This matches the spec.
- `WindowSizeMsg` uses early return (no viewport forward) — correct, `SetSize` handles the viewport resize directly.
- `KeyDown`/`KeyUp` arrow keys use early returns — no double-processing for arrows.

**Issues:**

**Bug: j/k double-processes through viewport.** The `case tea.KeyRunes` branch for "j" and "k" calls `b.Move()` but has no `return`. Execution falls through to `b.viewport.Update(msg)`. The viewport's default keymap (`bubbles@v1.0.0/viewport/keymap.go` lines 44/48) binds "j" to scroll-down and "k" to scroll-up. So pressing "j" moves the selected file index AND independently scrolls the viewport content. The test for down/up passes only because `SelectedIndex()` is checked (which is set by `Move()`) — the test does not catch the spurious scroll. Fix:

```go
case "j":
    b.Move(1)
    return b, nil   // missing
case "k":
    b.Move(-1)
    return b, nil   // missing
```

**Minor: `"q"` also falls through to viewport.** Same structural issue — "q" does return early (`return b, tea.Quit`) so this is fine for "q". No issue there.

**Assessment:** One real bug — j/k input is double-processed, causing the viewport to scroll its content on every file-navigation keystroke in addition to switching the displayed file. The `Move()` call already invokes `refreshViewport()` which resets content and calls `GotoTop()`, so the subsequent viewport scroll will immediately fight against that reset. The symptom is that after `refreshViewport()` sets position to top, `viewport.Update(msg)` then scrolls it down by one line. This is observable at runtime but the unit tests don't catch it. The fix is two `return b, nil` statements.

agentId: a8915404e98d54ceb (use SendMessage with to: 'a8915404e98d54ceb' to continue this agent)
<usage>total_tokens: 28978
tool_uses: 18
duration_ms: 153671</usage>

Prompt:
Combined spec + code-quality review of Task 13 (Browser tea.Model + TTY integration) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 8b740bd..89436d6 -- apps/birdseye/`

## Spec — What Was Requested

1. `apps/birdseye/internal/tui/browser.go` — append `Init() tea.Cmd` (returns nil) and `Update(msg tea.Msg) (tea.Model, tea.Cmd)` that handles `WindowSizeMsg`, `KeyDown`, `KeyUp`, `"q"` (tea.Quit), `"j"` (down), `"k"` (up), and forwards everything else to the inner viewport.

2. `apps/birdseye/internal/tui/browser_test.go` — append `TestBrowserMovesDownAndUp` and `TestBrowserQuitMessage`.

3. `apps/birdseye/internal/tui/app.go` — create `RunBrowser(ctx, diff.Result) error` using `tea.NewProgram(b, tea.WithAltScreen(), tea.WithContext(ctx))`.

4. `apps/birdseye/cmd/diff.go` — at end of `runDiff`, gate on `!o.Plain && isTTY(...)` to call `tui.RunBrowser(ctx, result)`, else `renderPlain`. Add `isTTY(w io.Writer) bool` using `golang.org/x/term`.

5. `go.mod` — add `golang.org/x/term` direct, ensure `bubbletea` direct.

Commit message exact: `feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration`.

## Quality Focus

- `Update` returns `*Browser` (concrete) wrapped as `tea.Model` — does `m.(*Browser)` work in tests?
- Forwarding `msg` to `viewport.Update` does NOT cause double-processing of keys we already handled (we only fall through on unhandled types)?
- `tea.Quit` properly returned as `tea.Cmd` rather than `tea.QuitMsg`?
- `isTTY` correctly type-asserts `w` to `*os.File`?
- `runDiff` flow: TTY+plain=false → TUI; pipe or plain → renderPlain; tests use `--plain` so they bypass TUI.

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues / Assessment

Be terse.


Real bug found in `Update`: `j`/`k` fall through to viewport which also binds those keys, causing double-processing. Quick fix.


Fix Task 13 j/k bug

Prompt:
Fix a real bug in `apps/birdseye/internal/tui/browser.go` inside worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## The Bug

In `Browser.Update`, the `tea.KeyRunes` switch cases for "j" and "k" call `b.Move(...)` but do NOT return. Execution falls through to `b.viewport.Update(msg)` at the bottom of the function. The bubbles viewport's default keymap also binds "j"/"k" to scroll the content, so each press both navigates files AND scrolls the viewport — a double-processing bug.

## The Fix

In `apps/birdseye/internal/tui/browser.go`, locate this block:

```go
case "j":
    b.Move(1)
case "k":
    b.Move(-1)
```

Change to:

```go
case "j":
    b.Move(1)
    return b, nil
case "k":
    b.Move(-1)
    return b, nil
```

## Add a Regression Test

Append to `apps/birdseye/internal/tui/browser_test.go`:

```go
func TestBrowserJKDoesNotForwardToViewport(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	// Press "j" — should advance selection and NOT return a tea.Cmd
	// (a non-nil cmd would indicate the message was forwarded to the
	// viewport, which would scroll the diff content as a side effect).
	_, cmd := b.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
	if b.SelectedIndex() != 1 {
		t.Errorf("after j: index = %d, want 1", b.SelectedIndex())
	}
	if cmd != nil {
		t.Errorf("expected nil cmd (no viewport forward); got %v", cmd)
	}
	_, cmd = b.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
	if b.SelectedIndex() != 0 {
		t.Errorf("after k: index = %d, want 0", b.SelectedIndex())
	}
	if cmd != nil {
		t.Errorf("expected nil cmd (no viewport forward); got %v", cmd)
	}
}
```

## Verify

- [ ] `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v` — all tests pass, including the new one
- [ ] `go vet ./...` clean

## Commit

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/browser.go apps/birdseye/internal/tui/browser_test.go && git commit -m "fix(birdseye): prevent j/k double-processing through viewport in browser"
```

## Report

- **Status:** DONE | BLOCKED
- Test result
- Commit SHA


**Status: DONE**

**What was fixed:**

In `/apps/birdseye/internal/tui/browser.go`, the `tea.KeyRunes` cases for `"j"` and `"k"` were missing `return b, nil` after calling `b.Move(...)`. Without the early return, execution fell through to `b.viewport.Update(msg)` at the bottom of `Update`, causing the bubbles viewport's built-in `j`/`k` scroll bindings to also fire — double-processing each keypress.

Added `return b, nil` after each `b.Move(...)` call, consistent with the already-correct `tea.KeyDown` and `tea.KeyUp` cases above them.

**Regression test added:** `TestBrowserJKDoesNotForwardToViewport` in `browser_test.go` — verifies that pressing `j`/`k` advances the selection index correctly and returns a `nil` cmd (meaning the message was not forwarded to the viewport).

**Test result:** All 5 TUI tests pass, all other package tests pass, `go vet` clean.

**Commit SHA:** `3f29025`

agentId: a596ec08b1f2a4232 (use SendMessage with to: 'a596ec08b1f2a4232' to continue this agent)
<usage>total_tokens: 18540
tool_uses: 7
duration_ms: 46884</usage>

Prompt:
Fix a real bug in `apps/birdseye/internal/tui/browser.go` inside worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

## The Bug

In `Browser.Update`, the `tea.KeyRunes` switch cases for "j" and "k" call `b.Move(...)` but do NOT return. Execution falls through to `b.viewport.Update(msg)` at the bottom of the function. The bubbles viewport's default keymap also binds "j"/"k" to scroll the content, so each press both navigates files AND scrolls the viewport — a double-processing bug.

## The Fix

In `apps/birdseye/internal/tui/browser.go`, locate this block:

```go
case "j":
    b.Move(1)
case "k":
    b.Move(-1)
```

Change to:

```go
case "j":
    b.Move(1)
    return b, nil
case "k":
    b.Move(-1)
    return b, nil
```

## Add a Regression Test

Append to `apps/birdseye/internal/tui/browser_test.go`:

```go
func TestBrowserJKDoesNotForwardToViewport(t *testing.T) {
	b := NewBrowser(sampleResult())
	b.SetSize(120, 30)
	// Press "j" — should advance selection and NOT return a tea.Cmd
	// (a non-nil cmd would indicate the message was forwarded to the
	// viewport, which would scroll the diff content as a side effect).
	_, cmd := b.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
	if b.SelectedIndex() != 1 {
		t.Errorf("after j: index = %d, want 1", b.SelectedIndex())
	}
	if cmd != nil {
		t.Errorf("expected nil cmd (no viewport forward); got %v", cmd)
	}
	_, cmd = b.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
	if b.SelectedIndex() != 0 {
		t.Errorf("after k: index = %d, want 0", b.SelectedIndex())
	}
	if cmd != nil {
		t.Errorf("expected nil cmd (no viewport forward); got %v", cmd)
	}
}
```

## Verify

- [ ] `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./... -v` — all tests pass, including the new one
- [ ] `go vet ./...` clean

## Commit

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/browser.go apps/birdseye/internal/tui/browser_test.go && git commit -m "fix(birdseye): prevent j/k double-processing through viewport in browser"
```

## Report

- **Status:** DONE | BLOCKED
- Test result
- Commit SHA


Bug fixed. Moving to Task 14 (interactive Picker state machine — the most complex TUI task).


Implement Task 14: interactive Picker

Prompt:
You are implementing Task 14 of the birdseye CLI implementation plan — the most complex TUI piece.

## Working Environment

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

## Context

Task 13 built the Browser as a `tea.Model` and wired it into `cmd diff` for TTY one-shot use. Your job: an interactive Picker that walks the user through module address → version A → version B → loading → browser. The Picker is itself a `tea.Model` with an internal state enum.

Task 15 (next) wires the Picker into `cmd diff` for the no-args invocation flow.

Critical pieces:
- The Picker handles its own state machine via a `PickerState` enum.
- It accepts a `Deps{Client, Reg}` struct so tests can construct a Picker WITHOUT a real client (for input-only tests). When `deps.Client == nil`, network-dispatching transitions are no-ops.
- Network work happens via `tea.Cmd` functions that return typed messages (`versionsLoadedMsg`, `pairLoadedMsg`).
- After `StateBrowser` is reached, the cmd integration (Task 15) needs to extract the fetched bytes and build a Browser. The Picker exposes `Bytes() ([]byte, []byte)` and `SetBrowser(*Browser)` for that handoff.

This task is unusually large. Take it carefully. If you get stuck on bubbletea API specifics (e.g., the exact bubbles/list constructor signature in v1.0.0), check the imports of `apps/birdseye/internal/tui/browser.go` for the bubbles version we have, and read the local `go.sum` / sibling code if needed.

## Task Description (Verbatim — from the plan)

### Task 14: TUI — interactive picker for no-args invocation

**Files:**
- Create: `apps/birdseye/internal/tui/picker.go`
- Create: `apps/birdseye/internal/tui/picker_test.go`
- Modify: `apps/birdseye/internal/tui/app.go` (you may add a comment for the future Run() entry point, but DO NOT add a runtime Run function in this task — Task 15 handles that wiring)

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

```go
package tui

import (
	"testing"

	tea "github.com/charmbracelet/bubbletea"
)

func TestPickerStartsAtAskModule(t *testing.T) {
	p := NewPicker(nil)
	if p.State() != StateAskModule {
		t.Errorf("initial state = %v, want StateAskModule", p.State())
	}
}

func TestPickerAdvancesOnEnter(t *testing.T) {
	p := NewPicker(nil)
	for _, r := range "iac/comply/aws" {
		_, _ = p.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
	}
	_, cmd := p.Update(tea.KeyMsg{Type: tea.KeyEnter})
	// nil deps means no network cmd is dispatched, but state has advanced
	// past validation. (See test in next assertion.)
	_ = cmd
	if p.HasError() {
		t.Errorf("did not expect error for valid module; got: %v", p)
	}
}

func TestPickerRejectsBadAddressInline(t *testing.T) {
	p := NewPicker(nil)
	for _, r := range "garbage" {
		_, _ = p.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
	}
	_, cmd := p.Update(tea.KeyMsg{Type: tea.KeyEnter})
	if cmd != nil {
		t.Error("did not expect a tea.Cmd for invalid address")
	}
	if !p.HasError() {
		t.Error("expected inline error on invalid address")
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (`undefined: NewPicker`, etc.)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/tui/... -run TestPicker -v`

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

```go
package tui

import (
	"context"
	"fmt"
	"strings"

	"github.com/charmbracelet/bubbles/list"
	"github.com/charmbracelet/bubbles/spinner"
	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"

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

// PickerState is the explicit state machine the picker walks.
type PickerState int

const (
	StateAskModule PickerState = iota
	StateAskVersionA
	StateAskVersionB
	StateLoading
	StateBrowser
	StateError
)

// Deps groups the picker's external dependencies. nil is acceptable for
// unit tests of the picker's input/state machinery (those tests never
// reach StateLoading).
type Deps struct {
	Client *client.Client
	Reg    *registry.Registry
}

type versionsLoadedMsg struct {
	versions []string
	err      error
}

type pairLoadedMsg struct {
	a, b []byte
	err  error
}

// versionItem implements bubbles/list.Item for a single version string.
type versionItem string

func (v versionItem) FilterValue() string { return string(v) }
func (v versionItem) Title() string       { return string(v) }
func (v versionItem) Description() string { return "" }

// Picker is a single bubbletea Model whose internal state enum drives
// which sub-widget is rendered.
type Picker struct {
	deps       *Deps
	state      PickerState
	input      textinput.Model
	moduleID   api.ModuleIdentifier
	versionsA  list.Model
	versionsB  list.Model
	chosenA    string
	chosenB    string
	spinner    spinner.Model
	browser    *Browser
	width      int
	height     int
	errMessage string
	pairA      []byte
	pairB      []byte
}

// NewPicker constructs a picker. deps may be nil for input-only tests
// (no network dispatch occurs in that case).
func NewPicker(deps *Deps) *Picker {
	ti := textinput.New()
	ti.Placeholder = "namespace/name/provider"
	ti.Focus()
	sp := spinner.New()
	sp.Spinner = spinner.Dot
	return &Picker{
		deps:    deps,
		state:   StateAskModule,
		input:   ti,
		spinner: sp,
	}
}

func (p *Picker) State() PickerState { return p.state }
func (p *Picker) HasError() bool     { return p.errMessage != "" }

// Bytes returns the fetched tarballs once StateBrowser is reached, so
// the cmd integration can extract + diff and build the browser.
func (p *Picker) Bytes() ([]byte, []byte) { return p.pairA, p.pairB }

// SetBrowser hands a pre-built Browser to the picker. Used by the cmd
// integration after FetchPair completes (the cmd does the extract+diff
// step and supplies the resulting browser here).
func (p *Picker) SetBrowser(b *Browser) {
	p.browser = b
	if p.width > 0 {
		b.SetSize(p.width, p.height)
	}
	p.state = StateBrowser
}

func (p *Picker) Init() tea.Cmd { return textinput.Blink }

func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		p.width, p.height = msg.Width, msg.Height
		if p.browser != nil {
			p.browser.SetSize(msg.Width, msg.Height)
		}
		return p, nil
	case tea.KeyMsg:
		if msg.Type == tea.KeyCtrlC ||
			(msg.Type == tea.KeyRunes && string(msg.Runes) == "q" && p.state != StateAskModule) {
			return p, tea.Quit
		}
	}
	switch p.state {
	case StateAskModule:
		return p.updateAskModule(msg)
	case StateAskVersionA:
		return p.updateAskVersionA(msg)
	case StateAskVersionB:
		return p.updateAskVersionB(msg)
	case StateLoading:
		return p.updateLoading(msg)
	case StateBrowser:
		if p.browser == nil {
			return p, tea.Quit
		}
		m, cmd := p.browser.Update(msg)
		p.browser = m.(*Browser)
		return p, cmd
	}
	return p, nil
}

func (p *Picker) updateAskModule(msg tea.Msg) (tea.Model, tea.Cmd) {
	if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
		id, err := parseModuleAddressForPicker(p.input.Value())
		if err != nil {
			p.errMessage = err.Error()
			return p, nil
		}
		p.errMessage = ""
		p.moduleID = id
		if p.deps == nil || p.deps.Client == nil {
			// Test harness path: do not dispatch network cmd.
			return p, nil
		}
		p.state = StateLoading
		return p, p.loadVersionsCmd(id)
	}
	var cmd tea.Cmd
	p.input, cmd = p.input.Update(msg)
	return p, cmd
}

func (p *Picker) loadVersionsCmd(id api.ModuleIdentifier) tea.Cmd {
	return func() tea.Msg {
		versions, err := p.deps.Client.ListVersions(context.Background(), id)
		return versionsLoadedMsg{versions: versions, err: err}
	}
}

func (p *Picker) updateLoading(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch m := msg.(type) {
	case versionsLoadedMsg:
		if m.err != nil {
			p.errMessage = m.err.Error()
			p.state = StateError
			return p, nil
		}
		items := make([]list.Item, 0, len(m.versions))
		for _, v := range m.versions {
			items = append(items, versionItem(v))
		}
		p.versionsA = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
		p.versionsA.Title = "Select version A"
		p.state = StateAskVersionA
		return p, nil
	case pairLoadedMsg:
		if m.err != nil {
			p.errMessage = m.err.Error()
			p.state = StateError
			return p, nil
		}
		p.pairA = m.a
		p.pairB = m.b
		// Stay in StateLoading until SetBrowser is called by the cmd
		// integration. To make the bubbletea program quit (so the cmd
		// code can run the second-stage Run), return tea.Quit here. The
		// cmd integration will inspect Bytes() and either run the
		// browser or report an error.
		return p, tea.Quit
	}
	var cmd tea.Cmd
	p.spinner, cmd = p.spinner.Update(msg)
	return p, cmd
}

func (p *Picker) updateAskVersionA(msg tea.Msg) (tea.Model, tea.Cmd) {
	if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
		item, ok := p.versionsA.SelectedItem().(versionItem)
		if !ok {
			return p, nil
		}
		p.chosenA = string(item)
		items := make([]list.Item, 0, len(p.versionsA.Items()))
		for _, it := range p.versionsA.Items() {
			if v := it.(versionItem); string(v) != p.chosenA {
				items = append(items, v)
			}
		}
		p.versionsB = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
		p.versionsB.Title = "Select version B"
		p.state = StateAskVersionB
		return p, nil
	}
	var cmd tea.Cmd
	p.versionsA, cmd = p.versionsA.Update(msg)
	return p, cmd
}

func (p *Picker) updateAskVersionB(msg tea.Msg) (tea.Model, tea.Cmd) {
	if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
		item, ok := p.versionsB.SelectedItem().(versionItem)
		if !ok {
			return p, nil
		}
		p.chosenB = string(item)
		if p.deps == nil || p.deps.Reg == nil {
			return p, nil
		}
		p.state = StateLoading
		return p, p.loadPairCmd(p.moduleID, p.chosenA, p.chosenB)
	}
	var cmd tea.Cmd
	p.versionsB, cmd = p.versionsB.Update(msg)
	return p, cmd
}

func (p *Picker) loadPairCmd(id api.ModuleIdentifier, va, vb string) tea.Cmd {
	return func() tea.Msg {
		a, b, err := p.deps.Reg.FetchPair(context.Background(),
			api.Module{ModuleIdentifier: id, Version: va},
			api.Module{ModuleIdentifier: id, Version: vb})
		return pairLoadedMsg{a: a, b: b, err: err}
	}
}

func (p *Picker) View() string {
	switch p.state {
	case StateAskModule:
		errBlock := ""
		if p.errMessage != "" {
			errBlock = "\nerror: " + p.errMessage
		}
		return "module address (namespace/name/provider):\n" + p.input.View() + errBlock
	case StateLoading:
		return p.spinner.View() + " loading…"
	case StateAskVersionA:
		return p.versionsA.View()
	case StateAskVersionB:
		return p.versionsB.View()
	case StateBrowser:
		if p.browser != nil {
			return p.browser.View()
		}
	case StateError:
		return "error: " + p.errMessage + "\npress q to quit"
	}
	return ""
}

// parseModuleAddressForPicker mirrors cmd.parseModuleAddress without
// the import cycle. Returns a typed error for inline display in the TUI.
func parseModuleAddressForPicker(s string) (api.ModuleIdentifier, error) {
	parts := strings.Split(s, "/")
	if len(parts) != 3 {
		return api.ModuleIdentifier{}, fmt.Errorf("module address must be namespace/name/provider, got %q", s)
	}
	id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
	if err := id.Validate(); err != nil {
		return api.ModuleIdentifier{}, err
	}
	return id, nil
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/tui/... -v`
Expected: All PASS (previous tui tests + 3 picker tests).

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

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

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/picker.go apps/birdseye/internal/tui/picker_test.go && git commit -m "feat(birdseye): interactive picker state machine for no-args diff"
```

Run `git status` first to verify nothing else is staged. If `go.mod`/`go.sum` need an update (textinput/list/spinner are new direct imports), include them too.

## Notes & Style

- Resist adding features beyond the plan. No fuzzy version search, no module autocomplete, no breadcrumbs. Just the state machine.
- The `parseModuleAddressForPicker` is a small duplication of `cmd.parseModuleAddress` to avoid an import cycle (cmd imports tui via Task 15; tui can't import cmd). Don't try to be clever — keep them separate.
- After `pairLoadedMsg` lands, `updateLoading` returns `tea.Quit` rather than transitioning to `StateBrowser`. The reason is two-stage: the cmd integration in Task 15 will (1) run the picker until it quits, (2) extract+diff via archive/diff packages, (3) start a SECOND `tea.NewProgram` running the Browser. This avoids tui depending on archive/diff packages at compile time.
- `bubbles/list`, `bubbles/spinner`, `bubbles/textinput` are new direct imports. `go mod tidy` should promote them.
- Errors during loading flow to `StateError` and stay there until user quits with `q`.

## Code Organization

`picker.go` is ~250 lines but cohesive — one state machine, one Model. Don't split.

## Self-Review Checklist

- [ ] All 3 new picker tests pass (`TestPickerStartsAtAskModule`, `TestPickerAdvancesOnEnter`, `TestPickerRejectsBadAddressInline`)
- [ ] All previous TUI tests still pass (Browser tests must NOT regress)
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] `bubbles/list`, `bubbles/spinner`, `bubbles/textinput` are direct deps
- [ ] `NewPicker(nil)` does NOT crash and `State()` returns `StateAskModule`
- [ ] Picker quits on `Ctrl+C` from any state, and on `q` from any state EXCEPT `StateAskModule` (so users can type the letter q in the module input)
- [ ] Commit message exactly: `feat(birdseye): interactive picker state machine for no-args diff`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created/modified
- Test count + race-clean
- go.mod state for new bubbles sub-packages
- Commit SHA
- Anything unexpected


Clean (the `.claude/mind.mv2.lock` is unrelated tooling state, not part of this task).

---

**Status:** DONE

**Files created/modified:**
- Created: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/picker.go` (252 lines)
- Created: `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/picker_test.go` (40 lines)
- Modified: `apps/birdseye/go.mod` — added `github.com/atotto/clipboard v0.1.4` and `github.com/sahilm/fuzzy v0.1.1` as indirect deps (pulled in by `bubbles/textinput` and `bubbles/list` respectively)
- Modified: `apps/birdseye/go.sum` — corresponding checksum entries

**Test count:** 8 total TUI tests pass race-clean (5 Browser + 3 Picker). `go vet ./...` clean. `go build ./...` succeeds.

**go.mod state:** `bubbles v1.0.0` was already a direct dep. The two new transitive deps (`atotto/clipboard`, `sahilm/fuzzy`) were promoted to indirect entries in `go.mod` by `go mod tidy` — this is expected and correct.

**Commit SHA:** `b9ac21b`

**Nothing unexpected** — the only wrinkle was the missing `go.sum` entries for `bubbles/textinput` and `bubbles/list` (these sub-packages weren't previously imported directly), resolved cleanly with `go mod tidy`.

agentId: a0020d735e71f6019 (use SendMessage with to: 'a0020d735e71f6019' to continue this agent)
<usage>total_tokens: 34583
tool_uses: 30
duration_ms: 394483</usage>

Prompt:
You are implementing Task 14 of the birdseye CLI implementation plan — the most complex TUI piece.

## Working Environment

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

## Context

Task 13 built the Browser as a `tea.Model` and wired it into `cmd diff` for TTY one-shot use. Your job: an interactive Picker that walks the user through module address → version A → version B → loading → browser. The Picker is itself a `tea.Model` with an internal state enum.

Task 15 (next) wires the Picker into `cmd diff` for the no-args invocation flow.

Critical pieces:
- The Picker handles its own state machine via a `PickerState` enum.
- It accepts a `Deps{Client, Reg}` struct so tests can construct a Picker WITHOUT a real client (for input-only tests). When `deps.Client == nil`, network-dispatching transitions are no-ops.
- Network work happens via `tea.Cmd` functions that return typed messages (`versionsLoadedMsg`, `pairLoadedMsg`).
- After `StateBrowser` is reached, the cmd integration (Task 15) needs to extract the fetched bytes and build a Browser. The Picker exposes `Bytes() ([]byte, []byte)` and `SetBrowser(*Browser)` for that handoff.

This task is unusually large. Take it carefully. If you get stuck on bubbletea API specifics (e.g., the exact bubbles/list constructor signature in v1.0.0), check the imports of `apps/birdseye/internal/tui/browser.go` for the bubbles version we have, and read the local `go.sum` / sibling code if needed.

## Task Description (Verbatim — from the plan)

### Task 14: TUI — interactive picker for no-args invocation

**Files:**
- Create: `apps/birdseye/internal/tui/picker.go`
- Create: `apps/birdseye/internal/tui/picker_test.go`
- Modify: `apps/birdseye/internal/tui/app.go` (you may add a comment for the future Run() entry point, but DO NOT add a runtime Run function in this task — Task 15 handles that wiring)

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

```go
package tui

import (
	"testing"

	tea "github.com/charmbracelet/bubbletea"
)

func TestPickerStartsAtAskModule(t *testing.T) {
	p := NewPicker(nil)
	if p.State() != StateAskModule {
		t.Errorf("initial state = %v, want StateAskModule", p.State())
	}
}

func TestPickerAdvancesOnEnter(t *testing.T) {
	p := NewPicker(nil)
	for _, r := range "iac/comply/aws" {
		_, _ = p.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
	}
	_, cmd := p.Update(tea.KeyMsg{Type: tea.KeyEnter})
	// nil deps means no network cmd is dispatched, but state has advanced
	// past validation. (See test in next assertion.)
	_ = cmd
	if p.HasError() {
		t.Errorf("did not expect error for valid module; got: %v", p)
	}
}

func TestPickerRejectsBadAddressInline(t *testing.T) {
	p := NewPicker(nil)
	for _, r := range "garbage" {
		_, _ = p.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
	}
	_, cmd := p.Update(tea.KeyMsg{Type: tea.KeyEnter})
	if cmd != nil {
		t.Error("did not expect a tea.Cmd for invalid address")
	}
	if !p.HasError() {
		t.Error("expected inline error on invalid address")
	}
}
```

- [ ] **Step 2: Run, expect FAIL** (`undefined: NewPicker`, etc.)

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test ./internal/tui/... -run TestPicker -v`

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

```go
package tui

import (
	"context"
	"fmt"
	"strings"

	"github.com/charmbracelet/bubbles/list"
	"github.com/charmbracelet/bubbles/spinner"
	"github.com/charmbracelet/bubbles/textinput"
	tea "github.com/charmbracelet/bubbletea"

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

// PickerState is the explicit state machine the picker walks.
type PickerState int

const (
	StateAskModule PickerState = iota
	StateAskVersionA
	StateAskVersionB
	StateLoading
	StateBrowser
	StateError
)

// Deps groups the picker's external dependencies. nil is acceptable for
// unit tests of the picker's input/state machinery (those tests never
// reach StateLoading).
type Deps struct {
	Client *client.Client
	Reg    *registry.Registry
}

type versionsLoadedMsg struct {
	versions []string
	err      error
}

type pairLoadedMsg struct {
	a, b []byte
	err  error
}

// versionItem implements bubbles/list.Item for a single version string.
type versionItem string

func (v versionItem) FilterValue() string { return string(v) }
func (v versionItem) Title() string       { return string(v) }
func (v versionItem) Description() string { return "" }

// Picker is a single bubbletea Model whose internal state enum drives
// which sub-widget is rendered.
type Picker struct {
	deps       *Deps
	state      PickerState
	input      textinput.Model
	moduleID   api.ModuleIdentifier
	versionsA  list.Model
	versionsB  list.Model
	chosenA    string
	chosenB    string
	spinner    spinner.Model
	browser    *Browser
	width      int
	height     int
	errMessage string
	pairA      []byte
	pairB      []byte
}

// NewPicker constructs a picker. deps may be nil for input-only tests
// (no network dispatch occurs in that case).
func NewPicker(deps *Deps) *Picker {
	ti := textinput.New()
	ti.Placeholder = "namespace/name/provider"
	ti.Focus()
	sp := spinner.New()
	sp.Spinner = spinner.Dot
	return &Picker{
		deps:    deps,
		state:   StateAskModule,
		input:   ti,
		spinner: sp,
	}
}

func (p *Picker) State() PickerState { return p.state }
func (p *Picker) HasError() bool     { return p.errMessage != "" }

// Bytes returns the fetched tarballs once StateBrowser is reached, so
// the cmd integration can extract + diff and build the browser.
func (p *Picker) Bytes() ([]byte, []byte) { return p.pairA, p.pairB }

// SetBrowser hands a pre-built Browser to the picker. Used by the cmd
// integration after FetchPair completes (the cmd does the extract+diff
// step and supplies the resulting browser here).
func (p *Picker) SetBrowser(b *Browser) {
	p.browser = b
	if p.width > 0 {
		b.SetSize(p.width, p.height)
	}
	p.state = StateBrowser
}

func (p *Picker) Init() tea.Cmd { return textinput.Blink }

func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.WindowSizeMsg:
		p.width, p.height = msg.Width, msg.Height
		if p.browser != nil {
			p.browser.SetSize(msg.Width, msg.Height)
		}
		return p, nil
	case tea.KeyMsg:
		if msg.Type == tea.KeyCtrlC ||
			(msg.Type == tea.KeyRunes && string(msg.Runes) == "q" && p.state != StateAskModule) {
			return p, tea.Quit
		}
	}
	switch p.state {
	case StateAskModule:
		return p.updateAskModule(msg)
	case StateAskVersionA:
		return p.updateAskVersionA(msg)
	case StateAskVersionB:
		return p.updateAskVersionB(msg)
	case StateLoading:
		return p.updateLoading(msg)
	case StateBrowser:
		if p.browser == nil {
			return p, tea.Quit
		}
		m, cmd := p.browser.Update(msg)
		p.browser = m.(*Browser)
		return p, cmd
	}
	return p, nil
}

func (p *Picker) updateAskModule(msg tea.Msg) (tea.Model, tea.Cmd) {
	if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
		id, err := parseModuleAddressForPicker(p.input.Value())
		if err != nil {
			p.errMessage = err.Error()
			return p, nil
		}
		p.errMessage = ""
		p.moduleID = id
		if p.deps == nil || p.deps.Client == nil {
			// Test harness path: do not dispatch network cmd.
			return p, nil
		}
		p.state = StateLoading
		return p, p.loadVersionsCmd(id)
	}
	var cmd tea.Cmd
	p.input, cmd = p.input.Update(msg)
	return p, cmd
}

func (p *Picker) loadVersionsCmd(id api.ModuleIdentifier) tea.Cmd {
	return func() tea.Msg {
		versions, err := p.deps.Client.ListVersions(context.Background(), id)
		return versionsLoadedMsg{versions: versions, err: err}
	}
}

func (p *Picker) updateLoading(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch m := msg.(type) {
	case versionsLoadedMsg:
		if m.err != nil {
			p.errMessage = m.err.Error()
			p.state = StateError
			return p, nil
		}
		items := make([]list.Item, 0, len(m.versions))
		for _, v := range m.versions {
			items = append(items, versionItem(v))
		}
		p.versionsA = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
		p.versionsA.Title = "Select version A"
		p.state = StateAskVersionA
		return p, nil
	case pairLoadedMsg:
		if m.err != nil {
			p.errMessage = m.err.Error()
			p.state = StateError
			return p, nil
		}
		p.pairA = m.a
		p.pairB = m.b
		// Stay in StateLoading until SetBrowser is called by the cmd
		// integration. To make the bubbletea program quit (so the cmd
		// code can run the second-stage Run), return tea.Quit here. The
		// cmd integration will inspect Bytes() and either run the
		// browser or report an error.
		return p, tea.Quit
	}
	var cmd tea.Cmd
	p.spinner, cmd = p.spinner.Update(msg)
	return p, cmd
}

func (p *Picker) updateAskVersionA(msg tea.Msg) (tea.Model, tea.Cmd) {
	if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
		item, ok := p.versionsA.SelectedItem().(versionItem)
		if !ok {
			return p, nil
		}
		p.chosenA = string(item)
		items := make([]list.Item, 0, len(p.versionsA.Items()))
		for _, it := range p.versionsA.Items() {
			if v := it.(versionItem); string(v) != p.chosenA {
				items = append(items, v)
			}
		}
		p.versionsB = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
		p.versionsB.Title = "Select version B"
		p.state = StateAskVersionB
		return p, nil
	}
	var cmd tea.Cmd
	p.versionsA, cmd = p.versionsA.Update(msg)
	return p, cmd
}

func (p *Picker) updateAskVersionB(msg tea.Msg) (tea.Model, tea.Cmd) {
	if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
		item, ok := p.versionsB.SelectedItem().(versionItem)
		if !ok {
			return p, nil
		}
		p.chosenB = string(item)
		if p.deps == nil || p.deps.Reg == nil {
			return p, nil
		}
		p.state = StateLoading
		return p, p.loadPairCmd(p.moduleID, p.chosenA, p.chosenB)
	}
	var cmd tea.Cmd
	p.versionsB, cmd = p.versionsB.Update(msg)
	return p, cmd
}

func (p *Picker) loadPairCmd(id api.ModuleIdentifier, va, vb string) tea.Cmd {
	return func() tea.Msg {
		a, b, err := p.deps.Reg.FetchPair(context.Background(),
			api.Module{ModuleIdentifier: id, Version: va},
			api.Module{ModuleIdentifier: id, Version: vb})
		return pairLoadedMsg{a: a, b: b, err: err}
	}
}

func (p *Picker) View() string {
	switch p.state {
	case StateAskModule:
		errBlock := ""
		if p.errMessage != "" {
			errBlock = "\nerror: " + p.errMessage
		}
		return "module address (namespace/name/provider):\n" + p.input.View() + errBlock
	case StateLoading:
		return p.spinner.View() + " loading…"
	case StateAskVersionA:
		return p.versionsA.View()
	case StateAskVersionB:
		return p.versionsB.View()
	case StateBrowser:
		if p.browser != nil {
			return p.browser.View()
		}
	case StateError:
		return "error: " + p.errMessage + "\npress q to quit"
	}
	return ""
}

// parseModuleAddressForPicker mirrors cmd.parseModuleAddress without
// the import cycle. Returns a typed error for inline display in the TUI.
func parseModuleAddressForPicker(s string) (api.ModuleIdentifier, error) {
	parts := strings.Split(s, "/")
	if len(parts) != 3 {
		return api.ModuleIdentifier{}, fmt.Errorf("module address must be namespace/name/provider, got %q", s)
	}
	id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
	if err := id.Validate(); err != nil {
		return api.ModuleIdentifier{}, err
	}
	return id, nil
}
```

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

Run: `cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go test -race ./internal/tui/... -v`
Expected: All PASS (previous tui tests + 3 picker tests).

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

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

- [ ] **Step 6: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/internal/tui/picker.go apps/birdseye/internal/tui/picker_test.go && git commit -m "feat(birdseye): interactive picker state machine for no-args diff"
```

Run `git status` first to verify nothing else is staged. If `go.mod`/`go.sum` need an update (textinput/list/spinner are new direct imports), include them too.

## Notes & Style

- Resist adding features beyond the plan. No fuzzy version search, no module autocomplete, no breadcrumbs. Just the state machine.
- The `parseModuleAddressForPicker` is a small duplication of `cmd.parseModuleAddress` to avoid an import cycle (cmd imports tui via Task 15; tui can't import cmd). Don't try to be clever — keep them separate.
- After `pairLoadedMsg` lands, `updateLoading` returns `tea.Quit` rather than transitioning to `StateBrowser`. The reason is two-stage: the cmd integration in Task 15 will (1) run the picker until it quits, (2) extract+diff via archive/diff packages, (3) start a SECOND `tea.NewProgram` running the Browser. This avoids tui depending on archive/diff packages at compile time.
- `bubbles/list`, `bubbles/spinner`, `bubbles/textinput` are new direct imports. `go mod tidy` should promote them.
- Errors during loading flow to `StateError` and stay there until user quits with `q`.

## Code Organization

`picker.go` is ~250 lines but cohesive — one state machine, one Model. Don't split.

## Self-Review Checklist

- [ ] All 3 new picker tests pass (`TestPickerStartsAtAskModule`, `TestPickerAdvancesOnEnter`, `TestPickerRejectsBadAddressInline`)
- [ ] All previous TUI tests still pass (Browser tests must NOT regress)
- [ ] `go vet ./...` clean
- [ ] `go build ./...` succeeds
- [ ] `bubbles/list`, `bubbles/spinner`, `bubbles/textinput` are direct deps
- [ ] `NewPicker(nil)` does NOT crash and `State()` returns `StateAskModule`
- [ ] Picker quits on `Ctrl+C` from any state, and on `q` from any state EXCEPT `StateAskModule` (so users can type the letter q in the module input)
- [ ] Commit message exactly: `feat(birdseye): interactive picker state machine for no-args diff`

## Report Format

- **Status:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- Files created/modified
- Test count + race-clean
- go.mod state for new bubbles sub-packages
- Commit SHA
- Anything unexpected


Phase A: Bootstrap + Crow client (Tasks 1-3)

go.mod/Makefile/main.go, cobra root, ExitCode, client.Download

Phase B: Archive + Registry (Tasks 4-8)

archive.Extract w/ hardening, MemCache+DiskCache, Registry.FetchPair

Phase C: Textual diff + plain CLI (Tasks 9-11)

FileDiff types, gotextdiff, cmd diff --plain, versions subcommand

Phase D: TUI browser + picker (Tasks 12-15)

Bubbletea browser, picker state machine, integration into cmd diff

Phase E: Semantic + JSON + README (Tasks 16-19)

HCL semantic diff, --semantic wiring, --json output, README

Final code review + finishing-a-development-branch

Dispatch final reviewer on the full implementation, then invoke finishing-a-development-branch skill


Combined review Task 14

Prompt:
Combined spec + code-quality review of Task 14 (interactive Picker state machine) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 3f29025..b9ac21b -- apps/birdseye/`

## Spec — What Was Requested

Create `apps/birdseye/internal/tui/picker.go`:
- `PickerState` enum: `StateAskModule=0, StateAskVersionA, StateAskVersionB, StateLoading, StateBrowser, StateError`
- `Deps{Client *client.Client; Reg *registry.Registry}`
- Internal messages: `versionsLoadedMsg{versions []string; err error}`, `pairLoadedMsg{a, b []byte; err error}`
- `versionItem string` implementing `list.Item`
- `Picker` struct with all the noted fields (deps, state, input, moduleID, versionsA, versionsB, chosenA, chosenB, spinner, browser, width, height, errMessage, pairA, pairB)
- `NewPicker(deps *Deps) *Picker`
- `State() PickerState`, `HasError() bool`, `Bytes() ([]byte, []byte)`, `SetBrowser(*Browser)`
- `Init() tea.Cmd` returns `textinput.Blink`
- `Update(msg tea.Msg)` with state-machine routing, Ctrl+C from any state → tea.Quit, "q" from any non-AskModule state → tea.Quit
- `updateAskModule`: on Enter, parse address, set errMessage on failure, advance to StateLoading via `loadVersionsCmd`. If `deps==nil || deps.Client==nil`, stay (test harness path).
- `updateAskVersionA` / `updateAskVersionB`: list pickers, Enter selects; VersionB list excludes the chosen A.
- `updateLoading`: handles `versionsLoadedMsg` → transitions to AskVersionA (or StateError); handles `pairLoadedMsg` → stores bytes and returns `tea.Quit` (two-stage handoff); spinner ticks otherwise.
- `View()`: returns appropriate per-state rendering.
- `parseModuleAddressForPicker(s)` — independent of cmd.parseModuleAddress to avoid import cycle.

Create `apps/birdseye/internal/tui/picker_test.go` with 3 tests: starts at AskModule, advances on Enter with valid address, rejects bad address inline with error message set.

Commit message exact: `feat(birdseye): interactive picker state machine for no-args diff`.

## Quality Focus

- State machine correctness: any unreachable / dead states?
- Two-stage handoff: `pairLoadedMsg` triggers `tea.Quit` rather than direct `StateBrowser` transition (so cmd integration can rebuild the program with browser as root model).
- `q` blocked from `StateAskModule` to allow typing the letter q in the module input — verified by inspection of the special-case in Update.
- Resource cleanup: anything leaking?
- Test sanity: the 3 tests verify the right things (initial state, valid-input advancement, invalid-input rejection)?
- Are `bubbles/list`, `bubbles/spinner`, `bubbles/textinput` directly imported and resolved correctly?

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues (Critical/Important/Minor) / Assessment

Be terse.


All 3 tests pass and the build is clean. Here is the full review:

---

**Spec: ✅**

Every requirement is satisfied. Enum values match (`StateAskModule=0` through `StateError`), all struct fields present, all public methods correct signatures, `Init()` returns `textinput.Blink`, `q` is explicitly gated away from `StateAskModule`, `pairLoadedMsg` returns `tea.Quit` (not `StateBrowser`), `parseModuleAddressForPicker` is local to avoid the import cycle, and the commit message matches exactly.

---

**Quality**

Strengths:
- State machine is exhaustive and has no dead states. `StateError` is reachable (both load commands can fail), `StateBrowser` is reachable via `SetBrowser`. The fallthrough `return p, nil` at the bottom of `Update` is a safe catch-all for unknown states.
- Two-stage handoff is correctly implemented. `pairLoadedMsg` stores bytes and calls `tea.Quit`; the program never transitions to `StateBrowser` directly — that transition only happens through `SetBrowser` which is called by the cmd layer after it unpacks and diffs. The comment on lines 197–202 explains this clearly.
- `q`-in-input-field is correctly handled. The `p.state != StateAskModule` guard on line 121 means typing "q" in the address field routes to `updateAskModule → input.Update`, not `tea.Quit`.
- No goroutine leaks. Both `loadVersionsCmd` and `loadPairCmd` are one-shot closures dispatched as `tea.Cmd`; bubbletea manages their lifecycle. Both accept a `context.Background()` — adequate for a CLI tool; no timeout is needed for this use case at this layer.
- `bubbles/list`, `bubbles/spinner`, `bubbles/textinput` all imported directly and resolved (go.mod at `bubbles v1.0.0`; build clean).
- `make([]list.Item, 0, capacity)` pre-allocation used correctly.

Issues:

**Minor — `TestPickerAdvancesOnEnter` does not assert state advancement.** The spec says "advances on Enter with valid address." The test only asserts `!HasError()`, not that `p.State()` has moved past `StateAskModule`. With `nil` deps the state stays `StateAskModule` (the nil-deps guard at line 154 returns without advancing), so this test verifies only that no error was set — it does not verify the "advances" claim in its name. This is a mild test honesty gap; the test is not wrong but the name overpromises. Adding `if p.State() != StateAskModule { t.Error("...") }` would make the assertion match the name, or the name should be `TestPickerAcceptsValidAddressWithoutError`.

**Minor — `StateError` has no quit path except `q`, and `q` works from `StateError`.** This is correct, but the `View()` for `StateError` says "press q to quit" — that will also work, just worth confirming. It does (line 121 allows `q` from any non-`StateAskModule` state, and `StateError != StateAskModule`). No bug; confirmed correct.

**Minor — `versionsB` exclusion uses a type assertion without a comma-ok on line 217.** `it.(versionItem)` without the two-value form will panic if the list ever contains an item of a different type. This cannot happen today (only `versionItem` is added to both lists) but is a fragile pattern. Not a real risk given the closed system, but worth a note.

Assessment: production-ready. The test naming issue is cosmetic; the type assertion is a low-risk style nit. No critical or important issues.

agentId: aabe23316bc4dea8e (use SendMessage with to: 'aabe23316bc4dea8e' to continue this agent)
<usage>total_tokens: 27799
tool_uses: 10
duration_ms: 157596</usage>

Prompt:
Combined spec + code-quality review of Task 14 (interactive Picker state machine) in worktree `/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye`.

Diff: `git diff 3f29025..b9ac21b -- apps/birdseye/`

## Spec — What Was Requested

Create `apps/birdseye/internal/tui/picker.go`:
- `PickerState` enum: `StateAskModule=0, StateAskVersionA, StateAskVersionB, StateLoading, StateBrowser, StateError`
- `Deps{Client *client.Client; Reg *registry.Registry}`
- Internal messages: `versionsLoadedMsg{versions []string; err error}`, `pairLoadedMsg{a, b []byte; err error}`
- `versionItem string` implementing `list.Item`
- `Picker` struct with all the noted fields (deps, state, input, moduleID, versionsA, versionsB, chosenA, chosenB, spinner, browser, width, height, errMessage, pairA, pairB)
- `NewPicker(deps *Deps) *Picker`
- `State() PickerState`, `HasError() bool`, `Bytes() ([]byte, []byte)`, `SetBrowser(*Browser)`
- `Init() tea.Cmd` returns `textinput.Blink`
- `Update(msg tea.Msg)` with state-machine routing, Ctrl+C from any state → tea.Quit, "q" from any non-AskModule state → tea.Quit
- `updateAskModule`: on Enter, parse address, set errMessage on failure, advance to StateLoading via `loadVersionsCmd`. If `deps==nil || deps.Client==nil`, stay (test harness path).
- `updateAskVersionA` / `updateAskVersionB`: list pickers, Enter selects; VersionB list excludes the chosen A.
- `updateLoading`: handles `versionsLoadedMsg` → transitions to AskVersionA (or StateError); handles `pairLoadedMsg` → stores bytes and returns `tea.Quit` (two-stage handoff); spinner ticks otherwise.
- `View()`: returns appropriate per-state rendering.
- `parseModuleAddressForPicker(s)` — independent of cmd.parseModuleAddress to avoid import cycle.

Create `apps/birdseye/internal/tui/picker_test.go` with 3 tests: starts at AskModule, advances on Enter with valid address, rejects bad address inline with error message set.

Commit message exact: `feat(birdseye): interactive picker state machine for no-args diff`.

## Quality Focus

- State machine correctness: any unreachable / dead states?
- Two-stage handoff: `pairLoadedMsg` triggers `tea.Quit` rather than direct `StateBrowser` transition (so cmd integration can rebuild the program with browser as root model).
- `q` blocked from `StateAskModule` to allow typing the letter q in the module input — verified by inspection of the special-case in Update.
- Resource cleanup: anything leaking?
- Test sanity: the 3 tests verify the right things (initial state, valid-input advancement, invalid-input rejection)?
- Are `bubbles/list`, `bubbles/spinner`, `bubbles/textinput` directly imported and resolved correctly?

## Report Format

**Spec:** ✅ / ❌
**Quality:** Strengths / Issues (Critical/Important/Minor) / Assessment

Be terse.


Task 14 APPROVED. Moving to Task 15 — wire the picker into `cmd diff` for no-args.


Implement Task 15: picker wiring

Prompt:
You are implementing Task 15 of the birdseye CLI implementation plan — the last task in Phase D.

## Working Environment

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

## Context

Task 14 created the interactive Picker (`*tui.Picker`) which is a `tea.Model` running a state machine that ends by calling `tea.Quit` (without entering `StateBrowser`) once both module versions have been fetched. The picker exposes `Bytes() ([]byte, []byte)` so the caller can extract + diff + build a Browser, then run a SECOND `tea.NewProgram` with the Browser as root model.

Your job: replace the "interactive mode not yet implemented" branch in `runDiff` with a two-stage runner that:
1. Builds a `client.Client` + `registry.Registry`
2. Runs the picker until it quits
3. Reads the fetched bytes via `Bytes()`
4. If both bytes are non-nil, extracts both archives, computes diff, runs the Browser

This is a small wiring task.

## Task Description (Verbatim — from the plan)

### Task 15: Wire picker into `cmd diff` for no-args invocation

**Files:**
- Modify: `apps/birdseye/cmd/diff.go`

- [ ] **Step 1: Replace the "interactive mode not yet implemented" branch** in `runDiff`:

```go
	if len(args) == 0 {
		return runInteractive(ctx, cmd, g, o)
	}
```

(Drop the previous `return fmt.Errorf("%w: interactive mode not yet implemented..."` line.)

- [ ] **Step 2: Add the two-stage runner** to `apps/birdseye/cmd/diff.go`. Append below `runDiff` (and the existing helpers):

```go
import (
	tea "github.com/charmbracelet/bubbletea"

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

(Merge into the existing import block. Don't add a second `import` clause.)

```go
func runInteractive(ctx context.Context, cmd *cobra.Command, g *GlobalFlags, o *diffOpts) error {
	if !isTTY(cmd.OutOrStdout()) {
		return fmt.Errorf("%w: interactive mode requires a TTY", ErrUsage)
	}
	cli := client.New(g.CrowURL, client.WithHTTPClient(newHTTPClient(g.Timeout)))
	cache, err := registry.NewDiskCache(o.CacheDir)
	if err != nil {
		return fmt.Errorf("cache init: %w", err)
	}
	reg := registry.New(cli, cache)

	// Stage 1: picker → fetched bytes
	p := tui.NewPicker(&tui.Deps{Client: cli, Reg: reg})
	prog := tea.NewProgram(p, tea.WithAltScreen(), tea.WithContext(ctx))
	if _, err := prog.Run(); err != nil {
		return err
	}
	aBytes, bBytes := p.Bytes()
	if aBytes == nil || bBytes == nil {
		// User quit before completing picker; not an error.
		return nil
	}

	// Stage 2: extract + diff + browser
	modA, err := archive.Extract(aBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}
	modB, err := archive.Extract(bBytes, maxArchiveBytes)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrArchive, err)
	}
	result := diff.Result{Files: diff.Textual(modA, modB)}
	return tui.RunBrowser(ctx, result)
}
```

- [ ] **Step 3: 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 no-args path is not covered by automated tests because it requires a TTY, so the existing cmd tests still verify the positional-args path.

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

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

- [ ] **Step 5: Commit**

```bash
cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye && git add apps/birdseye/cmd/diff.go && git commit -m "feat(birdseye): wire picker -> browser for no-args diff"
```

## Notes & Style

- The two-stage approach is intentional: `tui` cannot import `archive` or `diff` (those packages would create cycles or duplicate code). The cmd layer composes them.
- The "user quit before completing picker" case (Bytes returns nil, nil) returns nil success — Ctrl+C or `q` during picker should NOT produce a non-zero exit code.
- `runInteractive` runs the picker program FIRST, blocks until it quits, THEN runs the Browser program. Two separate `tea.NewProgram` invocations.
- Don't add new tests in this task — the existing test surface still covers the positional-args path which is what test environments use anyway.

## Self-Review Checklist

- [ ] `runDiff` correctly delegates 0-args to `runInteractive`
- [ ] `runInteractive` checks `isTTY` first and returns ErrUsage if not a TTY
- [ ] `prog.Run()` is called BEFORE `Bytes()` is read
- [ ] If both `aBytes` and `bBytes` are non-nil → extract + diff + RunBrowser
- [ ] If either is nil (user quit) → return nil
- [ ] Archive extract errors wrap with `%w: %v` against `ErrArchive`
- [ ] `go vet ./...` clean
- [ ] All existing tests pass
- [ ] Commit message exactly: `feat(birdseye): wire picker -> browser for no-args diff`
- [ ] Only `apps/birdseye/cmd/diff.go` staged

## Report Format

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