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.


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


I'll review the diff between commits 3f29025 and b9ac21b for the picker state machine.


Bash

cd /Users/steve.briggs/projects/operations && git diff 3f29025..b9ac21b -- apps/birdseye/ 2>/dev/null | head -5

Check if diff is accessible


List tui directory in worktree


diff --git a/apps/birdseye/go.mod b/apps/birdseye/go.mod
index d56ffe8..c93a7b5 100644
--- a/apps/birdseye/go.mod
+++ b/apps/birdseye/go.mod
@@ -14,6 +14,7 @@ require (


drwxr-xr-x steve.briggs staff 192 B  Tue May 12 23:55:20 2026 ..
.rw-r--r-- steve.briggs staff 1.6 KB Tue May 12 23:56:10 2026 styles.go
.rw-r--r-- steve.briggs staff 462 B  Wed May 13 00:00:00 2026 app.go
.rw-r--r-- steve.briggs staff 4.4 KB Wed May 13 00:05:03 2026 browser.go
.rw-r--r-- steve.briggs staff 2.6 KB Wed May 13 00:05:12 2026 browser_test.go
.rw-r--r-- steve.briggs staff 1.1 KB Wed May 13 00:11:27 2026 picker_test.go
.rw-r--r-- steve.briggs staff 7.9 KB Wed May 13 00:12:09 2026 picker.go
drwxr-xr-x steve.briggs staff 256 B  Wed May 13 00:12:09 2026 .


1	package tui
2
3	import (
4		"context"
5		"fmt"
6		"strings"
7
8		"github.com/charmbracelet/bubbles/list"
9		"github.com/charmbracelet/bubbles/spinner"
10		"github.com/charmbracelet/bubbles/textinput"
11		tea "github.com/charmbracelet/bubbletea"
12
13		"github.com/immuta/operations/apps/birdseye/internal/registry"
14		"github.com/immuta/operations/services/crow/pkg/api"
15		"github.com/immuta/operations/services/crow/pkg/client"
16	)
17
18	// PickerState is the explicit state machine the picker walks.
19	type PickerState int
20
21	const (
22		StateAskModule PickerState = iota
23		StateAskVersionA
24		StateAskVersionB
25		StateLoading
26		StateBrowser
27		StateError
28	)
29
30	// Deps groups the picker's external dependencies. nil is acceptable for
31	// unit tests of the picker's input/state machinery (those tests never
32	// reach StateLoading).
33	type Deps struct {
34		Client *client.Client
35		Reg    *registry.Registry
36	}
37
38	type versionsLoadedMsg struct {
39		versions []string
40		err      error
41	}
42
43	type pairLoadedMsg struct {
44		a, b []byte
45		err  error
46	}
47
48	// versionItem implements bubbles/list.Item for a single version string.
49	type versionItem string
50
51	func (v versionItem) FilterValue() string { return string(v) }
52	func (v versionItem) Title() string       { return string(v) }
53	func (v versionItem) Description() string { return "" }
54
55	// Picker is a single bubbletea Model whose internal state enum drives
56	// which sub-widget is rendered.
57	type Picker struct {
58		deps       *Deps
59		state      PickerState
60		input      textinput.Model
61		moduleID   api.ModuleIdentifier
62		versionsA  list.Model
63		versionsB  list.Model
64		chosenA    string
65		chosenB    string
66		spinner    spinner.Model
67		browser    *Browser
68		width      int
69		height     int
70		errMessage string
71		pairA      []byte
72		pairB      []byte
73	}
74
75	// NewPicker constructs a picker. deps may be nil for input-only tests
76	// (no network dispatch occurs in that case).
77	func NewPicker(deps *Deps) *Picker {
78		ti := textinput.New()
79		ti.Placeholder = "namespace/name/provider"
80		ti.Focus()
81		sp := spinner.New()
82		sp.Spinner = spinner.Dot
83		return &Picker{
84			deps:    deps,
85			state:   StateAskModule,
86			input:   ti,
87			spinner: sp,
88		}
89	}
90
91	func (p *Picker) State() PickerState { return p.state }
92	func (p *Picker) HasError() bool     { return p.errMessage != "" }
93
94	// Bytes returns the fetched tarballs once StateBrowser is reached, so
95	// the cmd integration can extract + diff and build the browser.
96	func (p *Picker) Bytes() ([]byte, []byte) { return p.pairA, p.pairB }
97
98	// SetBrowser hands a pre-built Browser to the picker. Used by the cmd
99	// integration after FetchPair completes (the cmd does the extract+diff
100	// step and supplies the resulting browser here).
101	func (p *Picker) SetBrowser(b *Browser) {
102		p.browser = b
103		if p.width > 0 {
104			b.SetSize(p.width, p.height)
105		}
106		p.state = StateBrowser
107	}
108
109	func (p *Picker) Init() tea.Cmd { return textinput.Blink }
110
111	func (p *Picker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112		switch msg := msg.(type) {
113		case tea.WindowSizeMsg:
114			p.width, p.height = msg.Width, msg.Height
115			if p.browser != nil {
116				p.browser.SetSize(msg.Width, msg.Height)
117			}
118			return p, nil
119		case tea.KeyMsg:
120			if msg.Type == tea.KeyCtrlC ||
121				(msg.Type == tea.KeyRunes && string(msg.Runes) == "q" && p.state != StateAskModule) {
122				return p, tea.Quit
123			}
124		}
125		switch p.state {
126		case StateAskModule:
127			return p.updateAskModule(msg)
128		case StateAskVersionA:
129			return p.updateAskVersionA(msg)
130		case StateAskVersionB:
131			return p.updateAskVersionB(msg)
132		case StateLoading:
133			return p.updateLoading(msg)
134		case StateBrowser:
135			if p.browser == nil {
136				return p, tea.Quit
137			}
138			m, cmd := p.browser.Update(msg)
139			p.browser = m.(*Browser)
140			return p, cmd
141		}
142		return p, nil
143	}
144
145	func (p *Picker) updateAskModule(msg tea.Msg) (tea.Model, tea.Cmd) {
146		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
147			id, err := parseModuleAddressForPicker(p.input.Value())
148			if err != nil {
149				p.errMessage = err.Error()
150				return p, nil
151			}
152			p.errMessage = ""
153			p.moduleID = id
154			if p.deps == nil || p.deps.Client == nil {
155				// Test harness path: do not dispatch network cmd.
156				return p, nil
157			}
158			p.state = StateLoading
159			return p, p.loadVersionsCmd(id)
160		}
161		var cmd tea.Cmd
162		p.input, cmd = p.input.Update(msg)
163		return p, cmd
164	}
165
166	func (p *Picker) loadVersionsCmd(id api.ModuleIdentifier) tea.Cmd {
167		return func() tea.Msg {
168			versions, err := p.deps.Client.ListVersions(context.Background(), id)
169			return versionsLoadedMsg{versions: versions, err: err}
170		}
171	}
172
173	func (p *Picker) updateLoading(msg tea.Msg) (tea.Model, tea.Cmd) {
174		switch m := msg.(type) {
175		case versionsLoadedMsg:
176			if m.err != nil {
177				p.errMessage = m.err.Error()
178				p.state = StateError
179				return p, nil
180			}
181			items := make([]list.Item, 0, len(m.versions))
182			for _, v := range m.versions {
183				items = append(items, versionItem(v))
184			}
185			p.versionsA = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
186			p.versionsA.Title = "Select version A"
187			p.state = StateAskVersionA
188			return p, nil
189		case pairLoadedMsg:
190			if m.err != nil {
191				p.errMessage = m.err.Error()
192				p.state = StateError
193				return p, nil
194			}
195			p.pairA = m.a
196			p.pairB = m.b
197			// Stay in StateLoading until SetBrowser is called by the cmd
198			// integration. To make the bubbletea program quit (so the cmd
199			// code can run the second-stage Run), return tea.Quit here. The
200			// cmd integration will inspect Bytes() and either run the
201			// browser or report an error.
202			return p, tea.Quit
203		}
204		var cmd tea.Cmd
205		p.spinner, cmd = p.spinner.Update(msg)
206		return p, cmd
207	}
208
209	func (p *Picker) updateAskVersionA(msg tea.Msg) (tea.Model, tea.Cmd) {
210		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
211			item, ok := p.versionsA.SelectedItem().(versionItem)
212			if !ok {
213				return p, nil
214			}
215			p.chosenA = string(item)
216			items := make([]list.Item, 0, len(p.versionsA.Items()))
217			for _, it := range p.versionsA.Items() {
218				if v := it.(versionItem); string(v) != p.chosenA {
219					items = append(items, v)
220				}
221			}
222			p.versionsB = list.New(items, list.NewDefaultDelegate(), p.width, p.height-4)
223			p.versionsB.Title = "Select version B"
224			p.state = StateAskVersionB
225			return p, nil
226		}
227		var cmd tea.Cmd
228		p.versionsA, cmd = p.versionsA.Update(msg)
229		return p, cmd
230	}
231
232	func (p *Picker) updateAskVersionB(msg tea.Msg) (tea.Model, tea.Cmd) {
233		if km, ok := msg.(tea.KeyMsg); ok && km.Type == tea.KeyEnter {
234			item, ok := p.versionsB.SelectedItem().(versionItem)
235			if !ok {
236				return p, nil
237			}
238			p.chosenB = string(item)
239			if p.deps == nil || p.deps.Reg == nil {
240				return p, nil
241			}
242			p.state = StateLoading
243			return p, p.loadPairCmd(p.moduleID, p.chosenA, p.chosenB)
244		}
245		var cmd tea.Cmd
246		p.versionsB, cmd = p.versionsB.Update(msg)
247		return p, cmd
248	}
249
250	func (p *Picker) loadPairCmd(id api.ModuleIdentifier, va, vb string) tea.Cmd {
251		return func() tea.Msg {
252			a, b, err := p.deps.Reg.FetchPair(context.Background(),
253				api.Module{ModuleIdentifier: id, Version: va},
254				api.Module{ModuleIdentifier: id, Version: vb})
255			return pairLoadedMsg{a: a, b: b, err: err}
256		}
257	}
258
259	func (p *Picker) View() string {
260		switch p.state {
261		case StateAskModule:
262			errBlock := ""
263			if p.errMessage != "" {
264				errBlock = "\nerror: " + p.errMessage
265			}
266			return "module address (namespace/name/provider):\n" + p.input.View() + errBlock
267		case StateLoading:
268			return p.spinner.View() + " loading…"
269		case StateAskVersionA:
270			return p.versionsA.View()
271		case StateAskVersionB:
272			return p.versionsB.View()
273		case StateBrowser:
274			if p.browser != nil {
275				return p.browser.View()
276			}
277		case StateError:
278			return "error: " + p.errMessage + "\npress q to quit"
279		}
280		return ""
281	}
282
283	// parseModuleAddressForPicker mirrors cmd.parseModuleAddress without
284	// the import cycle. Returns a typed error for inline display in the TUI.
285	func parseModuleAddressForPicker(s string) (api.ModuleIdentifier, error) {
286		parts := strings.Split(s, "/")
287		if len(parts) != 3 {
288			return api.ModuleIdentifier{}, fmt.Errorf("module address must be namespace/name/provider, got %q", s)
289		}
290		id := api.ModuleIdentifier{Namespace: parts[0], Name: parts[1], Provider: parts[2]}
291		if err := id.Validate(); err != nil {
292			return api.ModuleIdentifier{}, err
293		}
294		return id, nil
295	}
296


1	package tui
2
3	import (
4		"testing"
5
6		tea "github.com/charmbracelet/bubbletea"
7	)
8
9	func TestPickerStartsAtAskModule(t *testing.T) {
10		p := NewPicker(nil)
11		if p.State() != StateAskModule {
12			t.Errorf("initial state = %v, want StateAskModule", p.State())
13		}
14	}
15
16	func TestPickerAdvancesOnEnter(t *testing.T) {
17		p := NewPicker(nil)
18		for _, r := range "iac/comply/aws" {
19			_, _ = p.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
20		}
21		_, cmd := p.Update(tea.KeyMsg{Type: tea.KeyEnter})
22		// nil deps means no network cmd is dispatched, but state has advanced
23		// past validation. (See test in next assertion.)
24		_ = cmd
25		if p.HasError() {
26			t.Errorf("did not expect error for valid module; got: %v", p)
27		}
28	}
29
30	func TestPickerRejectsBadAddressInline(t *testing.T) {
31		p := NewPicker(nil)
32		for _, r := range "garbage" {
33			_, _ = p.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
34		}
35		_, cmd := p.Update(tea.KeyMsg{Type: tea.KeyEnter})
36		if cmd != nil {
37			t.Error("did not expect a tea.Cmd for invalid address")
38		}
39		if !p.HasError() {
40			t.Error("expected inline error on invalid address")
41		}
42	}
43


Now let me check the full diff and the go.mod to verify bubbles imports:


cd /Users/steve.briggs/projects/operations && git diff 3f29025..b9ac21b -- apps/birdseye/ 2>/dev/null

Get full diff


Check recent commits in worktree


diff --git a/apps/birdseye/go.mod b/apps/birdseye/go.mod
index d56ffe8..c93a7b5 100644
--- a/apps/birdseye/go.mod
+++ b/apps/birdseye/go.mod
@@ -14,6 +14,7 @@ require (
 )

 require (
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/charmbracelet/colorprofile v0.4.1 // indirect
 	github.com/charmbracelet/x/ansi v0.11.6 // indirect
@@ -32,6 +33,7 @@ require (
 	github.com/muesli/cancelreader v0.2.2 // indirect
 	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
+	github.com/sahilm/fuzzy v0.1.1 // indirect
 	github.com/spf13/pflag v1.0.6 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/sys v0.44.0 // indirect
diff --git a/apps/birdseye/go.sum b/apps/birdseye/go.sum
index 2102e5d..ae08691 100644
--- a/apps/birdseye/go.sum
+++ b/apps/birdseye/go.sum
@@ -1,5 +1,9 @@
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
 github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
 github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
 github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -12,6 +16,8 @@ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF
 github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
 github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
 github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
 github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
 github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
@@ -27,6 +33,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -44,6 +52,8 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
+github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
 github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
 github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
diff --git a/apps/birdseye/internal/tui/picker.go b/apps/birdseye/internal/tui/picker.go
new file mode 100644
index 0000000..33041ca
--- /dev/null
+++ b/apps/birdseye/internal/tui/picker.go
@@ -0,0 +1,295 @@
+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
+}
diff --git a/apps/birdseye/internal/tui/picker_test.go b/apps/birdseye/internal/tui/picker_test.go
new file mode 100644
index 0000000..a529aba
--- /dev/null
+++ b/apps/birdseye/internal/tui/picker_test.go
@@ -0,0 +1,42 @@
+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")
+	}
+}


b9ac21b feat(birdseye): interactive picker state machine for no-args diff
3f29025 fix(birdseye): prevent j/k double-processing through viewport in browser
89436d6 feat(birdseye): bubbletea browser tea.Model + cmd diff TTY integration
8b740bd chore(birdseye): promote bubbles and lipgloss to direct deps
d3eec79 feat(birdseye): tui browser scaffold with multi-pane layout


go build ./apps/birdseye/... 2>&1 | head -30

Attempt build to check for compile errors


pattern ./apps/birdseye/...: directory prefix apps/birdseye does not contain main module or its selected dependencies


1	package tui
2
3	import (
4		"strings"
5
6		tea "github.com/charmbracelet/bubbletea"
7		"github.com/charmbracelet/bubbles/viewport"
8		"github.com/charmbracelet/lipgloss"
9
10		"github.com/immuta/operations/apps/birdseye/internal/diff"
11	)
12
13	// Browser is the multi-pane diff viewer: file tree on the left,
14	// rendered unified diff on the right, footer at the bottom.
15	type Browser struct {
16		result   diff.Result
17		theme    Theme
18		selected int
19		width    int
20		height   int
21		viewport viewport.Model
22	}
23
24	// NewBrowser builds a Browser from a pre-computed Result. SetSize must
25	// be called once before the first View() so the viewport sizes itself.
26	func NewBrowser(r diff.Result) *Browser {
27		vp := viewport.New(0, 0)
28		return &Browser{
29			result:   r,
30			theme:    DefaultTheme(),


cd /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye && go build ./... 2>&1 | head -30

Build birdseye from correct directory


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

Run tui tests


(Bash completed with no output)


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


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.