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.


- 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 for Task 13. Let me start by loading the relevant context.


Launching skill: superpowers:using-superpowers


Base directory for this skill: /Users/steve.briggs/.claude/plugins/cache/claude-plugins-official/superpowers/5.1.0/skills/using-superpowers

<SUBAGENT-STOP>
If you were dispatched as a subagent to execute a specific task, skip this skill.
</SUBAGENT-STOP>

<EXTREMELY-IMPORTANT>
If you think there is even a 1% chance a skill might apply to what you are doing, you ABSOLUTELY MUST invoke the skill.

IF A SKILL APPLIES TO YOUR TASK, YOU DO NOT HAVE A CHOICE. YOU MUST USE IT.

This is not negotiable. This is not optional. You cannot rationalize your way out of this.
</EXTREMELY-IMPORTANT>

## Instruction Priority

Superpowers skills override default system prompt behavior, but **user instructions always take precedence**:

1. **User's explicit instructions** (CLAUDE.md, GEMINI.md, AGENTS.md, direct requests) — highest priority
2. **Superpowers skills** — override default system behavior where they conflict
3. **Default system prompt** — lowest priority

If CLAUDE.md, GEMINI.md, or AGENTS.md says "don't use TDD" and a skill says "always use TDD," follow the user's instructions. The user is in control.

## How to Access Skills

**In Claude Code:** Use the `Skill` tool. When you invoke a skill, its content is loaded and presented to you—follow it directly. Never use the Read tool on skill files.

**In Copilot CLI:** Use the `skill` tool. Skills are auto-discovered from installed plugins. The `skill` tool works the same as Claude Code's `Skill` tool.

**In Gemini CLI:** Skills activate via the `activate_skill` tool. Gemini loads skill metadata at session start and activates the full content on demand.

**In other environments:** Check your platform's documentation for how skills are loaded.

## Platform Adaptation

Skills use Claude Code tool names. Non-CC platforms: see `references/copilot-tools.md` (Copilot CLI), `references/codex-tools.md` (Codex) for tool equivalents. Gemini CLI users get the tool mapping loaded automatically via GEMINI.md.

# Using Skills

## The Rule

**Invoke relevant or requested skills BEFORE any response or action.** Even a 1% chance a skill might apply means that you should invoke the skill to check. If an invoked skill turns out to be wrong for the situation, you don't need to use it.

```dot
digraph skill_flow {
    "User message received" [shape=doublecircle];
    "About to EnterPlanMode?" [shape=doublecircle];
    "Already brainstormed?" [shape=diamond];
    "Invoke brainstorming skill" [shape=box];
    "Might any skill apply?" [shape=diamond];
    "Invoke Skill tool" [shape=box];
    "Announce: 'Using [skill] to [purpose]'" [shape=box];
    "Has checklist?" [shape=diamond];
    "Create TodoWrite todo per item" [shape=box];
    "Follow skill exactly" [shape=box];
    "Respond (including clarifications)" [shape=doublecircle];

    "About to EnterPlanMode?" -> "Already brainstormed?";
    "Already brainstormed?" -> "Invoke brainstorming skill" [label="no"];
    "Already brainstormed?" -> "Might any skill apply?" [label="yes"];
    "Invoke brainstorming skill" -> "Might any skill apply?";

    "User message received" -> "Might any skill apply?";
    "Might any skill apply?" -> "Invoke Skill tool" [label="yes, even 1%"];
    "Might any skill apply?" -> "Respond (including clarifications)" [label="definitely not"];
    "Invoke Skill tool" -> "Announce: 'Using [skill] to [purpose]'";
    "Announce: 'Using [skill] to [purpose]'" -> "Has checklist?";
    "Has checklist?" -> "Create TodoWrite todo per item" [label="yes"];
    "Has checklist?" -> "Follow skill exactly" [label="no"];
    "Create TodoWrite todo per item" -> "Follow skill exactly";
}
```

## Red Flags

These thoughts mean STOP—you're rationalizing:

| Thought | Reality |
|---------|---------|
| "This is just a simple question" | Questions are tasks. Check for skills. |
| "I need more context first" | Skill check comes BEFORE clarifying questions. |
| "Let me explore the codebase first" | Skills tell you HOW to explore. Check first. |
| "I can check git/files quickly" | Files lack conversation context. Check for skills. |
| "Let me gather information first" | Skills tell you HOW to gather information. |
| "This doesn't need a formal skill" | If a skill exists, use it. |
| "I remember this skill" | Skills evolve. Read current version. |
| "This doesn't count as a task" | Action = task. Check for skills. |
| "The skill is overkill" | Simple things become complex. Use it. |
| "I'll just do this one thing first" | Check BEFORE doing anything. |
| "This feels productive" | Undisciplined action wastes time. Skills prevent this. |
| "I know what that means" | Knowing the concept ≠ using the skill. Invoke it. |

## Skill Priority

When multiple skills could apply, use this order:

1. **Process skills first** (brainstorming, debugging) - these determine HOW to approach the task
2. **Implementation skills second** (frontend-design, mcp-builder) - these guide execution

"Let's build X" → brainstorming first, then implementation skills.
"Fix this bug" → debugging first, then domain-specific skills.

## Skill Types

**Rigid** (TDD, debugging): Follow exactly. Don't adapt away discipline.

**Flexible** (patterns): Adapt principles to context.

The skill itself tells you which.

## User Instructions

Instructions say WHAT, not HOW. "Add X" or "Fix Y" doesn't mean skip workflows.


Using the receiving-code-review skill for this review task.


Launching skill: superpowers:receiving-code-review


Base directory for this skill: /Users/steve.briggs/.claude/plugins/cache/claude-plugins-official/superpowers/5.1.0/skills/receiving-code-review

# Code Review Reception

## Overview

Code review requires technical evaluation, not emotional performance.

**Core principle:** Verify before implementing. Ask before assuming. Technical correctness over social comfort.

## The Response Pattern

```
WHEN receiving code review feedback:

1. READ: Complete feedback without reacting
2. UNDERSTAND: Restate requirement in own words (or ask)
3. VERIFY: Check against codebase reality
4. EVALUATE: Technically sound for THIS codebase?
5. RESPOND: Technical acknowledgment or reasoned pushback
6. IMPLEMENT: One item at a time, test each
```

## Forbidden Responses

**NEVER:**
- "You're absolutely right!" (explicit CLAUDE.md violation)
- "Great point!" / "Excellent feedback!" (performative)
- "Let me implement that now" (before verification)

**INSTEAD:**
- Restate the technical requirement
- Ask clarifying questions
- Push back with technical reasoning if wrong
- Just start working (actions > words)

## Handling Unclear Feedback

```
IF any item is unclear:
  STOP - do not implement anything yet
  ASK for clarification on unclear items

WHY: Items may be related. Partial understanding = wrong implementation.
```

**Example:**
```
your human partner: "Fix 1-6"
You understand 1,2,3,6. Unclear on 4,5.

❌ WRONG: Implement 1,2,3,6 now, ask about 4,5 later
✅ RIGHT: "I understand items 1,2,3,6. Need clarification on 4 and 5 before proceeding."
```

## Source-Specific Handling

### From your human partner
- **Trusted** - implement after understanding
- **Still ask** if scope unclear
- **No performative agreement**
- **Skip to action** or technical acknowledgment

### From External Reviewers
```
BEFORE implementing:
  1. Check: Technically correct for THIS codebase?
  2. Check: Breaks existing functionality?
  3. Check: Reason for current implementation?
  4. Check: Works on all platforms/versions?
  5. Check: Does reviewer understand full context?

IF suggestion seems wrong:
  Push back with technical reasoning

IF can't easily verify:
  Say so: "I can't verify this without [X]. Should I [investigate/ask/proceed]?"

IF conflicts with your human partner's prior decisions:
  Stop and discuss with your human partner first
```

**your human partner's rule:** "External feedback - be skeptical, but check carefully"

## YAGNI Check for "Professional" Features

```
IF reviewer suggests "implementing properly":
  grep codebase for actual usage

  IF unused: "This endpoint isn't called. Remove it (YAGNI)?"
  IF used: Then implement properly
```

**your human partner's rule:** "You and reviewer both report to me. If we don't need this feature, don't add it."

## Implementation Order

```
FOR multi-item feedback:
  1. Clarify anything unclear FIRST
  2. Then implement in this order:
     - Blocking issues (breaks, security)
     - Simple fixes (typos, imports)
     - Complex fixes (refactoring, logic)
  3. Test each fix individually
  4. Verify no regressions
```

## When To Push Back

Push back when:
- Suggestion breaks existing functionality
- Reviewer lacks full context
- Violates YAGNI (unused feature)
- Technically incorrect for this stack
- Legacy/compatibility reasons exist
- Conflicts with your human partner's architectural decisions

**How to push back:**
- Use technical reasoning, not defensiveness
- Ask specific questions
- Reference working tests/code
- Involve your human partner if architectural

**Signal if uncomfortable pushing back out loud:** "Strange things are afoot at the Circle K"

## Acknowledging Correct Feedback

When feedback IS correct:
```
✅ "Fixed. [Brief description of what changed]"
✅ "Good catch - [specific issue]. Fixed in [location]."
✅ [Just fix it and show in the code]

❌ "You're absolutely right!"
❌ "Great point!"
❌ "Thanks for catching that!"
❌ "Thanks for [anything]"
❌ ANY gratitude expression
```

**Why no thanks:** Actions speak. Just fix it. The code itself shows you heard the feedback.

**If you catch yourself about to write "Thanks":** DELETE IT. State the fix instead.

## Gracefully Correcting Your Pushback

If you pushed back and were wrong:
```
✅ "You were right - I checked [X] and it does [Y]. Implementing now."
✅ "Verified this and you're correct. My initial understanding was wrong because [reason]. Fixing."

❌ Long apology
❌ Defending why you pushed back
❌ Over-explaining
```

State the correction factually and move on.

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Performative agreement | State requirement or just act |
| Blind implementation | Verify against codebase first |
| Batch without testing | One at a time, test each |
| Assuming reviewer is right | Check if breaks things |
| Avoiding pushback | Technical correctness > comfort |
| Partial implementation | Clarify all items first |
| Can't verify, proceed anyway | State limitation, ask for direction |

## Real Examples

**Performative Agreement (Bad):**
```
Reviewer: "Remove legacy code"
❌ "You're absolutely right! Let me remove that..."
```

**Technical Verification (Good):**
```
Reviewer: "Remove legacy code"
✅ "Checking... build target is 10.15+, this API needs 13+. Need legacy for backward compat. Current impl has wrong bundle ID - fix it or drop pre-13 support?"
```

**YAGNI (Good):**
```
Reviewer: "Implement proper metrics tracking with database, date filters, CSV export"
✅ "Grepped codebase - nothing calls this endpoint. Remove it (YAGNI)? Or is there usage I'm missing?"
```

**Unclear Item (Good):**
```
your human partner: "Fix items 1-6"
You understand 1,2,3,6. Unclear on 4,5.
✅ "Understand 1,2,3,6. Need clarification on 4 and 5 before implementing."
```

## GitHub Thread Replies

When replying to inline review comments on GitHub, reply in the comment thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level PR comment.

## The Bottom Line

**External feedback = suggestions to evaluate, not orders to follow.**

Verify. Question. Then implement.

No performative agreement. Technical rigor always.


Now let me examine the actual diff and source files.


Bash

git diff 8b740bd..89436d6 -- apps/birdseye/

Get the full diff for Task 13


diff --git a/apps/birdseye/cmd/diff.go b/apps/birdseye/cmd/diff.go
index 111e784..dc41fbe 100644
--- a/apps/birdseye/cmd/diff.go
+++ b/apps/birdseye/cmd/diff.go
@@ -11,10 +11,12 @@ import (
 	"time"

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

 	"github.com/immuta/operations/apps/birdseye/internal/archive"
 	"github.com/immuta/operations/apps/birdseye/internal/diff"
 	"github.com/immuta/operations/apps/birdseye/internal/registry"
+	"github.com/immuta/operations/apps/birdseye/internal/tui"
 	"github.com/immuta/operations/services/crow/pkg/api"
 	"github.com/immuta/operations/services/crow/pkg/client"
 )
@@ -117,11 +119,22 @@ func runDiff(ctx context.Context, cmd *cobra.Command, args []string, g *GlobalFl

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

+	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()))
+}
+
 // renderPlain writes the diff result to w in unified-diff format.
 // "no differences" is emitted when there are zero files in the result.
 func renderPlain(w io.Writer, r diff.Result) error {
diff --git a/apps/birdseye/go.mod b/apps/birdseye/go.mod
index 24665cc..d56ffe8 100644
--- a/apps/birdseye/go.mod
+++ b/apps/birdseye/go.mod
@@ -4,16 +4,17 @@ go 1.25.5

 require (
 	github.com/charmbracelet/bubbles v1.0.0
+	github.com/charmbracelet/bubbletea v1.3.10
 	github.com/charmbracelet/lipgloss v1.1.0
 	github.com/hexops/gotextdiff v1.0.3
 	github.com/immuta/operations/services/crow v0.0.0-20260512210412-d8eb33139c1a
 	github.com/spf13/cobra v1.9.1
 	golang.org/x/sync v0.20.0
+	golang.org/x/term v0.43.0
 )

 require (
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
-	github.com/charmbracelet/bubbletea v1.3.10 // indirect
 	github.com/charmbracelet/colorprofile v0.4.1 // indirect
 	github.com/charmbracelet/x/ansi v0.11.6 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
@@ -33,7 +34,7 @@ require (
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/spf13/pflag v1.0.6 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
-	golang.org/x/sys v0.41.0 // indirect
+	golang.org/x/sys v0.44.0 // indirect
 	golang.org/x/text v0.34.0 // indirect
 )

diff --git a/apps/birdseye/go.sum b/apps/birdseye/go.sum
index 3e5894a..2102e5d 100644
--- a/apps/birdseye/go.sum
+++ b/apps/birdseye/go.sum
@@ -56,8 +56,10 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
 golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
 golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/apps/birdseye/internal/tui/app.go b/apps/birdseye/internal/tui/app.go
new file mode 100644
index 0000000..f20dbe7
--- /dev/null
+++ b/apps/birdseye/internal/tui/app.go
@@ -0,0 +1,18 @@
+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
+}
diff --git a/apps/birdseye/internal/tui/browser.go b/apps/birdseye/internal/tui/browser.go
index bfc0c4a..b20ca6e 100644
--- a/apps/birdseye/internal/tui/browser.go
+++ b/apps/birdseye/internal/tui/browser.go
@@ -3,6 +3,7 @@ package tui
 import (
 	"strings"

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

@@ -106,6 +107,40 @@ func (b *Browser) colorizeUnified(unified string) string {
 	return sb.String()
 }

+// Init satisfies tea.Model. No initial cmd — browser is purely reactive.
+func (b *Browser) Init() tea.Cmd { return nil }
+
+// Update handles key presses and window-resize messages.
+func (b *Browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		b.SetSize(msg.Width, msg.Height)
+		return b, nil
+	case tea.KeyMsg:
+		switch msg.Type {
+		case tea.KeyDown:
+			b.Move(1)
+			return b, nil
+		case tea.KeyUp:
+			b.Move(-1)
+			return b, nil
+		case tea.KeyRunes:
+			switch string(msg.Runes) {
+			case "q":
+				return b, tea.Quit
+			case "j":
+				b.Move(1)
+			case "k":
+				b.Move(-1)
+			}
+		}
+	}
+	// Forward to viewport for scroll handling.
+	var cmd tea.Cmd
+	b.viewport, cmd = b.viewport.Update(msg)
+	return b, cmd
+}
+
 // View renders the full multi-pane layout.
 func (b *Browser) View() string {
 	tree := b.renderTree()
diff --git a/apps/birdseye/internal/tui/browser_test.go b/apps/birdseye/internal/tui/browser_test.go
index 563300c..92c2ee4 100644
--- a/apps/birdseye/internal/tui/browser_test.go
+++ b/apps/birdseye/internal/tui/browser_test.go
@@ -3,6 +3,8 @@ package tui
 import (
 	"testing"

+	tea "github.com/charmbracelet/bubbletea"
+
 	"github.com/immuta/operations/apps/birdseye/internal/diff"
 )

@@ -42,3 +44,30 @@ func contains(s, sub string) bool {
 	}
 	return false
 }
+
+func TestBrowserMovesDownAndUp(t *testing.T) {
+	b := NewBrowser(sampleResult())
+	b.SetSize(120, 30)
+	m, _ := b.Update(tea.KeyMsg{Type: tea.KeyDown})
+	b2 := m.(*Browser)
+	if b2.SelectedIndex() != 1 {
+		t.Errorf("after down: index = %d, want 1", b2.SelectedIndex())
+	}
+	m, _ = b2.Update(tea.KeyMsg{Type: tea.KeyUp})
+	b3 := m.(*Browser)
+	if b3.SelectedIndex() != 0 {
+		t.Errorf("after up: index = %d, want 0", b3.SelectedIndex())
+	}
+}
+
+func TestBrowserQuitMessage(t *testing.T) {
+	b := NewBrowser(sampleResult())
+	b.SetSize(120, 30)
+	_, cmd := b.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
+	if cmd == nil {
+		t.Fatal("expected tea.Quit cmd")
+	}
+	if got, want := cmd(), tea.Quit(); got != want {
+		t.Errorf("cmd msg = %v, want %v", got, want)
+	}
+}


Now let me examine the existing browser.go source to understand the full context.


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(),
31			viewport: vp,
32		}
33	}
34
35	// SetSize informs the browser of the terminal dimensions.
36	func (b *Browser) SetSize(w, h int) {
37		b.width, b.height = w, h
38		fileTreeW := 32
39		if w < 80 {
40			fileTreeW = 24
41		}
42		contentW := w - fileTreeW - 4
43		contentH := h - 2
44		if contentW < 10 {
45			contentW = 10
46		}
47		if contentH < 5 {
48			contentH = 5
49		}
50		b.viewport.Width = contentW
51		b.viewport.Height = contentH
52		b.refreshViewport()
53	}
54
55	// SelectedIndex returns the currently focused file diff index.
56	func (b *Browser) SelectedIndex() int { return b.selected }
57
58	// SelectedFile returns the FileDiff at the current selection. Falls
59	// back to a zero value when the result has no files.
60	func (b *Browser) SelectedFile() diff.FileDiff {
61		if len(b.result.Files) == 0 {
62			return diff.FileDiff{}
63		}
64		return b.result.Files[b.selected]
65	}
66
67	// Move adjusts the selection by delta, clamping to the valid range, and
68	// refreshes the viewport content.
69	func (b *Browser) Move(delta int) {
70		if len(b.result.Files) == 0 {
71			return
72		}
73		b.selected += delta
74		if b.selected < 0 {
75			b.selected = 0
76		}
77		if b.selected >= len(b.result.Files) {
78			b.selected = len(b.result.Files) - 1
79		}
80		b.refreshViewport()
81	}
82
83	func (b *Browser) refreshViewport() {
84		if len(b.result.Files) == 0 {
85			b.viewport.SetContent("no differences")
86			return
87		}
88		b.viewport.SetContent(b.colorizeUnified(b.SelectedFile().Unified))
89		b.viewport.GotoTop()
90	}
91
92	func (b *Browser) colorizeUnified(unified string) string {
93		var sb strings.Builder
94		for _, line := range strings.Split(unified, "\n") {
95			switch {
96			case strings.HasPrefix(line, "@@"):
97				sb.WriteString(b.theme.HunkHeader.Render(line))
98			case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
99				sb.WriteString(b.theme.AddedLine.Render(line))
100			case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
101				sb.WriteString(b.theme.RemovedLine.Render(line))
102			default:
103				sb.WriteString(line)
104			}
105			sb.WriteByte('\n')
106		}
107		return sb.String()
108	}
109
110	// Init satisfies tea.Model. No initial cmd — browser is purely reactive.
111	func (b *Browser) Init() tea.Cmd { return nil }
112
113	// Update handles key presses and window-resize messages.
114	func (b *Browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115		switch msg := msg.(type) {
116		case tea.WindowSizeMsg:
117			b.SetSize(msg.Width, msg.Height)
118			return b, nil
119		case tea.KeyMsg:
120			switch msg.Type {
121			case tea.KeyDown:
122				b.Move(1)
123				return b, nil
124			case tea.KeyUp:
125				b.Move(-1)
126				return b, nil
127			case tea.KeyRunes:
128				switch string(msg.Runes) {
129				case "q":
130					return b, tea.Quit
131				case "j":
132					b.Move(1)
133				case "k":
134					b.Move(-1)
135				}
136			}
137		}
138		// Forward to viewport for scroll handling.
139		var cmd tea.Cmd
140		b.viewport, cmd = b.viewport.Update(msg)
141		return b, cmd
142	}
143
144	// View renders the full multi-pane layout.
145	func (b *Browser) View() string {
146		tree := b.renderTree()
147		right := b.theme.Viewport.Render(b.viewport.View())
148		body := lipgloss.JoinHorizontal(lipgloss.Top, tree, right)
149		footer := b.theme.Footer.Render("j/k or ↑/↓ move · q quit · esc back")
150		return lipgloss.JoinVertical(lipgloss.Left, body, footer)
151	}
152
153	func (b *Browser) renderTree() string {
154		var sb strings.Builder
155		for i, fd := range b.result.Files {
156			marker := " "
157			switch fd.Status {
158			case diff.StatusAdded:
159				marker = b.theme.StatusAdd.Render("+")
160			case diff.StatusRemoved:
161				marker = b.theme.StatusDel.Render("-")
162			case diff.StatusModified:
163				marker = b.theme.StatusMod.Render("~")
164			}
165			line := marker + " " + fd.Path
166			if i == b.selected {
167				line = b.theme.FileSelected.Render(line)
168			} else {
169				line = b.theme.FileTreeItem.Render(line)
170			}
171			sb.WriteString(line)
172			sb.WriteByte('\n')
173		}
174		if len(b.result.Files) == 0 {
175			sb.WriteString("(no files)")
176		}
177		return b.theme.FileTree.Render(sb.String())
178	}
179


1	package tui
2
3	import (
4		"testing"
5
6		tea "github.com/charmbracelet/bubbletea"
7
8		"github.com/immuta/operations/apps/birdseye/internal/diff"
9	)
10
11	func sampleResult() diff.Result {
12		return diff.Result{Files: []diff.FileDiff{
13			{Path: "a.tf", Status: diff.StatusModified, Unified: "@@\n-x\n+y\n", Adds: 1, Dels: 1},
14			{Path: "b.tf", Status: diff.StatusAdded, Unified: "+++ b.tf\n+new\n", Adds: 1},
15		}}
16	}
17
18	func TestNewBrowserDefaults(t *testing.T) {
19		b := NewBrowser(sampleResult())
20		if b.SelectedIndex() != 0 {
21			t.Errorf("default selected index = %d, want 0", b.SelectedIndex())
22		}
23		if b.SelectedFile().Path != "a.tf" {
24			t.Errorf("first selected = %s", b.SelectedFile().Path)
25		}
26	}
27
28	func TestBrowserViewContainsBothFiles(t *testing.T) {
29		b := NewBrowser(sampleResult())
30		b.SetSize(120, 30)
31		view := b.View()
32		for _, want := range []string{"a.tf", "b.tf"} {
33			if !contains(view, want) {
34				t.Errorf("view missing %q\n%s", want, view)
35			}
36		}
37	}
38
39	func contains(s, sub string) bool {
40		for i := 0; i+len(sub) <= len(s); i++ {
41			if s[i:i+len(sub)] == sub {
42				return true
43			}
44		}
45		return false
46	}
47
48	func TestBrowserMovesDownAndUp(t *testing.T) {
49		b := NewBrowser(sampleResult())
50		b.SetSize(120, 30)
51		m, _ := b.Update(tea.KeyMsg{Type: tea.KeyDown})
52		b2 := m.(*Browser)
53		if b2.SelectedIndex() != 1 {
54			t.Errorf("after down: index = %d, want 1", b2.SelectedIndex())
55		}
56		m, _ = b2.Update(tea.KeyMsg{Type: tea.KeyUp})
57		b3 := m.(*Browser)
58		if b3.SelectedIndex() != 0 {
59			t.Errorf("after up: index = %d, want 0", b3.SelectedIndex())
60		}
61	}
62
63	func TestBrowserQuitMessage(t *testing.T) {
64		b := NewBrowser(sampleResult())
65		b.SetSize(120, 30)
66		_, cmd := b.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
67		if cmd == nil {
68			t.Fatal("expected tea.Quit cmd")
69		}
70		if got, want := cmd(), tea.Quit(); got != want {
71			t.Errorf("cmd msg = %v, want %v", got, want)
72		}
73	}
74


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


Now let me check the double-processing concern specifically: when "j" or "k" is handled, does the code fall through to `viewport.Update`?


1	package tui
2
3	import (
4		"context"
5
6		tea "github.com/charmbracelet/bubbletea"
7
8		"github.com/immuta/operations/apps/birdseye/internal/diff"
9	)
10
11	// RunBrowser launches a full-screen bubbletea program rendering the
12	// supplied result. Returns when the user quits (q or Ctrl+C).
13	func RunBrowser(ctx context.Context, result diff.Result) error {
14		b := NewBrowser(result)
15		p := tea.NewProgram(b, tea.WithAltScreen(), tea.WithContext(ctx))
16		_, err := p.Run()
17		return err
18	}
19


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

Run the new browser tests


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


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

Run all birdseye tests


?   	github.com/immuta/operations/apps/birdseye	[no test files]
ok  	github.com/immuta/operations/apps/birdseye/cmd	1.188s
ok  	github.com/immuta/operations/apps/birdseye/internal/archive	0.642s
ok  	github.com/immuta/operations/apps/birdseye/internal/diff	1.313s
ok  	github.com/immuta/operations/apps/birdseye/internal/registry	0.615s
ok  	github.com/immuta/operations/apps/birdseye/internal/tui	0.670s


All tests pass. Let me verify the double-processing concern precisely.


114	func (b *Browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115		switch msg := msg.(type) {
116		case tea.WindowSizeMsg:
117			b.SetSize(msg.Width, msg.Height)
118			return b, nil
119		case tea.KeyMsg:
120			switch msg.Type {
121			case tea.KeyDown:
122				b.Move(1)
123				return b, nil
124			case tea.KeyUp:
125				b.Move(-1)
126				return b, nil
127			case tea.KeyRunes:
128				switch string(msg.Runes) {
129				case "q":
130					return b, tea.Quit
131				case "j":
132					b.Move(1)
133				case "k":
134					b.Move(-1)
135				}
136			}
137		}
138		// Forward to viewport for scroll handling.
139		var cmd tea.Cmd
140		b.viewport, cmd = b.viewport.Update(msg)
141		return b, cmd
142	}
143


The double-processing issue is now clear. Let me also verify the `tea.Quit` vs `tea.QuitMsg` concern.


grep -r "QuitMsg\|tea.Quit" /Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/ 2>&1

Check tea.Quit usage


/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/browser_test.go:		t.Fatal("expected tea.Quit cmd")
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/browser_test.go:	if got, want := cmd(), tea.Quit(); got != want {
/Users/steve.briggs/projects/operations/.claude/worktrees/birdseye/apps/birdseye/internal/tui/browser.go:				return b, tea.Quit


grep -r "func Quit" $(go env GOPATH)/pkg/mod/github.com/charmbracelet/bubbletea@v1.3.10/ 2>/dev/null | head -5 || find /Users/steve.briggs -path "*/charmbracelet/bubbletea*" -name "*.go" 2>/dev/null | xargs grep -l "func Quit" 2>/dev/null | head -3

Check tea.Quit signature in bubbletea module


/Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbletea@v1.3.10/tea.go:func Quit() Msg {


grep -A5 "^func Quit" /Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbletea@v1.3.10/tea.go

Check tea.Quit implementation


func Quit() Msg {
	return QuitMsg{}
}

// QuitMsg signals that the program should quit. You can send a [QuitMsg] with
// [Quit].


The `tea.Quit` in bubbletea v1.3.10 is a `func() Msg`, not a `tea.Cmd` directly. Let me verify this matters.


grep -n "type Cmd\|type Msg\b" /Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbletea@v1.3.10/tea.go | head -10

Check Cmd and Msg type definitions in bubbletea


41:type Msg interface{}
65:type Cmd func() Msg


`tea.Quit` is `func() Msg` which is exactly `tea.Cmd` — the types match. The test `cmd()` call returning `tea.QuitMsg{}` is correct, and `tea.Quit()` also returns `tea.QuitMsg{}`. Now let me confirm the double-processing issue for j/k.

The `Update` switch on `tea.KeyMsg` handles `KeyDown` and `KeyUp` with early returns. But `KeyRunes` for "j"/"k" do NOT return early — they fall through to `b.viewport.Update(msg)`. This means j/k both call `b.Move()` AND forward the key to the viewport. Whether this is a bug depends on how `viewport.Update` handles `KeyRunes` — the viewport may also respond to j/k internally.


grep -n '"j"\|"k"\|KeyRunes\|case.*j\b' /Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbles@v1.0.0/viewport/viewport.go 2>/dev/null | head -20

Check if bubbles viewport handles j/k keys


(Bash completed with no output)


find /Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbles@v1.0.0/viewport/ -name "*.go" | xargs grep -n "KeyMsg\|KeyRunes\|\"j\"\|\"k\"" 2>/dev/null | head -30

Check viewport key handling in bubbles v1.0.0


/Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbles@v1.0.0/viewport/keymap.go:44:			key.WithKeys("up", "k"),
/Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbles@v1.0.0/viewport/keymap.go:48:			key.WithKeys("down", "j"),
/Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbles@v1.0.0/viewport/viewport.go:418:	case tea.KeyMsg:


The viewport's default keymap includes "j" mapped to scroll-down and "k" to scroll-up. So when "j" is pressed, `Browser.Update` calls `b.Move(1)` to change the selected file AND then forwards to `viewport.Update(msg)` which also scrolls the viewport content down. This is a real double-processing bug for j/k.

The fix would be to add `return b, nil` after the j/k moves inside the `KeyRunes` switch — same pattern as `KeyDown`/`KeyUp`.


grep -n "KeyDown\|KeyUp\|keys\|\"up\"\|\"down\"" /Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbles@v1.0.0/viewport/viewport.go | head -20

Check if viewport also handles KeyDown/KeyUp arrow keys


grep -n "up\|down\|Half\|Page" /Users/steve.briggs/go/pkg/mod/github.com/charmbracelet/bubbles@v1.0.0/viewport/keymap.go

Check viewport keymap entries


14:	PageDown     key.Binding
15:	PageUp       key.Binding
16:	HalfPageUp   key.Binding
17:	HalfPageDown key.Binding
27:		PageDown: key.NewBinding(
28:			key.WithKeys("pgdown", spacebar, "f"),
29:			key.WithHelp("f/pgdn", "page down"),
31:		PageUp: key.NewBinding(
32:			key.WithKeys("pgup", "b"),
33:			key.WithHelp("b/pgup", "page up"),
35:		HalfPageUp: key.NewBinding(
37:			key.WithHelp("u", "½ page up"),
39:		HalfPageDown: key.NewBinding(
41:			key.WithHelp("d", "½ page down"),
44:			key.WithKeys("up", "k"),
45:			key.WithHelp("↑/k", "up"),
48:			key.WithKeys("down", "j"),
49:			key.WithHelp("↓/j", "down"),


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.