Add submarine TUI prototype with mock data

Bubble Tea prototype for browsing repositories with many nested git
submodules: collapsible tree, pins shown as ref + distance, summarized
issue words with severity levels, detail pane, async loading via a
Provider interface backed by mock data. Includes docs/ with
requirements, state model, UX guide, architecture and decision log,
plus AGENTS.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon Franken
2026-10-21 14:59:53 +02:00
co-authored by Claude Opus 5.5
commit 2212b24fe1
27 changed files with 2679 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# submarine — documentation
submarine is a terminal UI for git repositories with many (recursively nested)
submodules. It shows **only the submodules**, as a collapsible tree, and
describes each pin as a **branch plus distance** (`origin/main ↓1`) instead of a
bare commit hash.
## Documents
| Document | Contents |
|---|---|
| [requirements.md](requirements.md) | What the tool must do: functional and non-functional requirements |
| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs |
| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings |
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
## Status
**Prototype.** The UI is complete for browsing and runs on mock data
(`internal/submodule/mock`). There is no git backend yet, and actions
(`u`, `b`, `f`, `/`, `!`) only show a "not implemented" message.
Next steps, roughly in order:
1. Git provider implementing `submodule.Provider` (read-only).
2. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init.
3. Filter (`/`) and "problems only" (`!`) views.
4. Refresh after actions and on demand.
+105
View File
@@ -0,0 +1,105 @@
# Architecture
Go, [Bubble Tea](https://github.com/charmbracelet/bubbletea) (v1),
[Lip Gloss](https://github.com/charmbracelet/lipgloss),
[Bubbles](https://github.com/charmbracelet/bubbles) (key, help, spinner),
[bubblezone](https://github.com/lrstanley/bubblezone) (mouse hit areas).
## Package layout
```
cmd/submarine/main.go wiring: choose provider (--mock), start the program
internal/submodule/ domain model; NO UI imports
model.go Repo, Submodule, Status, PinInfo, Commit, enums
status.go Issues(), HiddenIssues(), Walk()
provider.go Provider interface
internal/submodule/mock/ fixture provider with artificial latency
internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view)
msgs.go treeLoadedMsg, statusLoadedMsg
theme/ colors, glyphs, issue words, level styles
tree/ tree component: rows, navigation, mouse, view
detail/ detail pane
header/ header line
footer/ help and flash messages
```
### Layering rules
- `internal/submodule` knows nothing about the UI. Everything that decides
**what** a state means (issue kinds, levels, hidden issues) lives here.
- `internal/tui/theme` decides **how** it looks: words, colors, symbols.
- Components (`tree`, `detail`, `header`, `footer`) use the theme; they never
define colors or classify states themselves.
- The UI only accesses data through `submodule.Provider`.
## Provider boundary
```go
type Provider interface {
Tree(ctx context.Context) (*Repo, error) // structure only, fast
Status(ctx context.Context, path string) (*Status, error) // per submodule, may be slow
}
```
- `Tree` returns the superproject and all submodules with `Parent` and
`Children` linked. `Status` fields are nil.
- `Status` is called once per submodule, concurrently. It must be safe to
call concurrently and must respect `ctx`.
- `Submodule.Path` (relative to the superproject root) is the unique ID.
The mock provider (`internal/submodule/mock`) covers every state and sleeps
100–800 ms per `Status` call to exercise the loading path.
## Loading flow
1. `Init` runs `Tree()` → `treeLoadedMsg`.
2. The app indexes submodules by path and starts one `tea.Cmd` per submodule
calling `Status()` → `statusLoadedMsg`.
3. Each result is stored on the submodule (`Status` or `Err`); the view
updates incrementally, including parents' hidden-issue counts.
4. The spinner stops ticking when nothing is loading.
## Tree component
- State: roots, `expanded map[path]bool`, cursor (a `*Submodule`), scroll
offset, size.
- `rows()` flattens the visible tree into directory-header rows and
submodule rows. Directory grouping uses `path.Dir(RelPath)` and keeps the
order of first appearance.
- Navigation methods (`Up`, `Down`, `Left`, `Right`, `Toggle`,
`CollapseAll`, `ExpandAll`, `Home`, `End`, `PageUp`, `PageDown`) are plain
methods without Bubble Tea dependencies, so they are unit-tested directly.
- Key mapping happens in `app.go`; the tree only handles mouse messages.
- Mouse zones: `toggle:<path>` and `row:<path>`, prefixed per instance.
## Rendering notes
- Each segment of a row is rendered with the row's `base` style
(background), because an inner style's reset would otherwise clear the
outer background.
- Widths are measured with `lipgloss.Width`; truncation uses
`ansi.Truncate`, which keeps escape sequences and bubblezone markers intact.
- `zone.Scan` runs once, on the root view.
## Testing
```sh
go vet ./...
go test ./...
```
- `internal/submodule`: issue classification and hidden-issue aggregation.
- `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see
`TestMain`).
Manual check in a headless terminal:
```sh
go build -o /tmp/submarine ./cmd/submarine
tmux new-session -d -s sub -x 130 -y 24 /tmp/submarine
tmux send-keys -t sub Down Right
tmux capture-pane -pt sub # add -e to see colors
tmux kill-session -t sub
```
+101
View File
@@ -0,0 +1,101 @@
# Decision log
Each entry: context, decision, consequences. Newest last.
## D1 — Show pins as ref + distance, not hashes
**Context.** Hashes don't tell users whether a pin is current.
**Decision.** Resolve each pin to a ref and show the distance (`origin/main ↓1`).
The resolution order is fixed and the applied rule is shown in the detail pane
(see [state-model.md](state-model.md#resolving-a-pin-to-a-ref)).
**Consequences.** Resolution is a heuristic; showing the rule keeps it
transparent.
## D2 — Compare three commits: pinned, checked out, tip
**Context.** The first mockup showed one branch for both the checkout and the
pin, so a mismatch was invisible.
**Decision.** Model all three commits. The tree's ref column always describes
the **pin**; checkout drift is an issue (`off pin`).
**Consequences.** The detail pane shows Pinned / Checkout / Tip rows.
## D3 — Raw dimensions in the domain, summarized issues in the UI
**Context.** States are not mutually exclusive (a submodule can be off pin and
dirty), but showing every dimension made rows noisy (14 symbols).
**Decision.** `Status` stores independent dimensions. `Status.Issues()`
summarizes them into five kinds with three levels. The tree shows only the
most severe issue (+n); the detail pane shows all.
**Consequences.** One place defines what a problem is; a git backend gets
the same behavior for free.
## D4 — Attention follows severity
**Context.** Healthy rows competed for attention with dangerous ones.
**Decision.** Three levels: quiet (muted row, no word), warn (yellow),
danger (red bold). Behind/ahead, staged pin and detached HEAD are quiet.
**Consequences.** A long list reads as grey, with problems standing out.
## D5 — Words instead of glyphs
**Context.** Symbols (`≠ ✚ ○ ⚠ ●`) were found unintuitive.
**Decision.** Short words: `unpushed`, `conflict`, `missing`, `off pin`,
`changed` (plus `error`). Symbols remain only for arrows and tree markers.
**Consequences.** Status column is 14 columns wide; no legend needed.
## D6 — "Unpushed pin" is the most severe state
**Context.** Committing a superproject whose pin isn't on any remote breaks
everyone else's checkout.
**Decision.** `unpushed` is Danger, listed first, and its `↑n` is red.
## D7 — Hidden issues bubble up to collapsed parents
**Context.** Only the first level is shown by default, so nested problems
would be invisible.
**Decision.** Collapsed parents show `·k` (issues in all descendants), colored
by the worst one. Problems in a child do **not** mark the parent itself as
`changed`.
## D8 — Collapsed by default, branch-first navigation
**Decision.** Only the first level is visible. `→` expands and enters the
first child, `←` collapses and goes to the parent, `space` toggles,
`ctrl+→/←` expand/collapse all (see [ux-guide.md](ux-guide.md#navigation)).
`←` on an expanded node collapses it in place, so a node can be closed
without leaving it. Remembered expansion is kept on collapse, but reset by
"collapse all".
## D9 — Directory headers are path groups, not nesting
**Decision.** Submodules are grouped by the directory of their path relative
to the parent repo. Headers (`src/`, `modules/`) are muted, non-selectable,
and skipped by navigation. Nesting is expressed by indentation under the
parent submodule.
## D10 — ANSI 16 colors
**Context.** Fixed 256-colors (e.g. violet 63) ignored the user's terminal
theme and were unreadable on light backgrounds.
**Decision.** Use ANSI 0–15 for all meaning (red, yellow, blue, cyan, grey,
default foreground). Only background shades use `AdaptiveColor`. Detect the
background before Bubble Tea starts.
## D11 — Provider boundary and async loading
**Decision.** The UI accesses data only through `submodule.Provider`:
`Tree()` for the fast structure, `Status(path)` per submodule, loaded
concurrently with a spinner per row. The prototype uses a mock provider with
artificial latency.
**Consequences.** A git backend can be added without UI changes; the UI
stays responsive with many submodules.
## D12 — No network on startup
**Decision.** Work from local refs and show their age; fetching is an
explicit action (`f`).
## D13 — Mouse support via bubblezone
**Decision.** Keep mouse support (click to select/toggle, wheel to move)
using bubblezone markers per row.
+104
View File
@@ -0,0 +1,104 @@
# Requirements
## Problem
In repositories with many submodules, `git submodule status` shows pinned
commit hashes. It doesn't answer the questions users actually have:
- Which branch is this submodule on, and is the pin current?
- Which submodules need my attention, and which are fine?
- Is anything dangerous, e.g. a pin that other people can't fetch?
## Goals
- Show **only submodules** (not files), recursively nested, as a tree.
- Describe pins as **ref + distance** instead of a commit hash.
- Let **problems stand out** and keep healthy submodules quiet.
- Work comfortably with **dozens to hundreds** of submodules.
## Functional requirements
### Tree
- **F1** Show all submodules of the superproject, including nested submodules
of submodules, at any depth.
- **F2** Group submodules by the directory of their path relative to the
parent repository. A directory with submodules is shown as a non-selectable
header line (e.g. `src/`, `modules/`). Submodules at the repository root
have no header.
- **F3** Nested submodules are shown under their parent submodule. A
directory header inside a submodule (e.g. `modules/`) means the nested
submodule lives in that subdirectory of the parent.
- **F4** On startup only the first level is shown; every submodule with
nested submodules is **collapsed**.
- **F5** A collapsed submodule shows the number of its **direct** children,
e.g. `[2]`.
- **F6** A collapsed submodule shows the number of issues **hidden** in all
its descendants (`·2`), colored by the most severe hidden issue.
- **F7** Expansion state of descendants is remembered when a parent is
collapsed and restored when it is expanded again (except after
"collapse all", which resets it).
### State per submodule
- **F10** Each submodule shows at most **one** state word: the most severe
issue, plus `+n` if there are more. Submodules without issues show no word.
See [state-model.md](state-model.md) for the full rules.
- **F11** Each submodule shows its pin as a ref (`origin/main`, a tag, or a
short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`).
- **F12** A pin that is not reachable from any remote ref ("unpushed") is
the most prominent state.
- **F13** States load asynchronously per submodule; a submodule shows a
spinner until its state is known. Loading errors are shown as `error`.
### Detail pane
- **F20** Shows the selected submodule: full path, name, all issues in
words, staged-pin and detached-HEAD notes.
- **F21** Shows the resolved ref, how it was resolved, and three commits:
**Pinned**, **Checkout** and **Tip** (SHA + subject). A checkout that
differs from the pin is highlighted.
- **F22** Lists the commits between pin and tip (SHA, subject, author, age)
with a title stating ahead/behind/diverged.
- **F23** Shows child count, issues below, and the remote URL.
### Header and footer
- **F30** The header shows the superproject path, its branch and
ahead/behind, the number of submodules, a count per issue kind (most
severe first), staged pins, how many are still loading, and the time since
the last fetch.
- **F31** The footer shows key hints; `?` toggles the full help. It
temporarily shows short messages (e.g. "not implemented").
### Navigation
- **F40** Keyboard navigation as specified in
[ux-guide.md](ux-guide.md#navigation).
- **F41** Mouse: click a row to select it, click `▸`/`▾` to toggle, scroll
wheel moves the selection.
### Actions (planned, not implemented)
| Key | Action |
|---|---|
| `u` | Check out the pinned commit (`git submodule update`) |
| `b` | Bump the pin to the tip of its ref |
| `f` | Fetch remotes |
| `/` | Filter by name/path |
| `!` | Show only submodules with issues |
## Non-functional requirements
- **N1** Startup must not block on the network. The tool works from local
refs and shows how old they are ("fetched 2m ago"); fetching is explicit.
- **N2** Loading many submodules must not freeze the UI (async, per
submodule).
- **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI
works on dark and light backgrounds.
- **N4** Meaning never depends on color alone: every state has a word.
- **N5** Works without a Nerd Font. `SUBMARINE_ASCII=1` replaces the few
remaining Unicode symbols with ASCII.
- **N6** Works in narrow terminals (detail pane hides below 90 columns).
- **N7** The UI is independent of the data source: replacing the mock
provider with a git provider requires no UI changes.
+108
View File
@@ -0,0 +1,108 @@
# State model
Code: `internal/submodule/model.go`, `internal/submodule/status.go`.
## Three commits per submodule
Every submodule involves three commits. All state is derived from comparing
them.
| Commit | Source |
|---|---|
| **Pinned** | The gitlink recorded in the superproject (HEAD or index) |
| **Checked out** | The submodule's `HEAD` |
| **Tip** | The tip of the ref the pin was resolved to, e.g. `origin/main` |
This gives two independent questions:
- **Is the checkout on the pin?** → `off pin` if not.
- **Where is the pin relative to its ref?** → `↑n` / `↓n` / `↑n↓m`.
## Raw status dimensions
`submodule.Status` stores the raw, independent dimensions. A submodule can be
in several at once.
| Field | Values |
|---|---|
| `Presence` | `PresenceOK`, `NotInitialized`, `DirMissing` |
| `Checkout` | `CheckoutMatch`, `CheckoutDiffers`, `CheckoutDetached` (on the pin, detached HEAD; informational) |
| `Worktree` | `WorktreeClean`, `WorktreeModified`, `WorktreeUntracked`, `WorktreeConflict` |
| `PinStaged` | Pin changed in the superproject index but not committed |
| `Pin` | `Ref`, `Source`, `Ahead`, `Behind`, `Pushed` |
| `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) |
| `Between` | Commits that make up the pin ↔ tip difference |
`Status == nil` means "still loading". `Submodule.Err` is set if loading
failed.
## Issues: summarizing the dimensions
The UI never shows raw dimensions in the tree. `Status.Issues()` summarizes
them into **issue kinds**, each with a **level**. This is the single source of
truth for what counts as a problem.
| Issue (word) | Combines | Level |
|---|---|---|
| `unpushed` | Pin not reachable from any remote ref | Danger |
| `conflict` | Merge conflict in the worktree | Danger |
| `missing` | Not initialized; directory missing | Warn |
| `off pin` | Checkout differs from pin | Warn |
| `changed` | Uncommitted changes; untracked files | Warn |
Rules:
- If the submodule is **missing**, that is its only issue (nothing else can
be determined).
- Issues are returned **most severe first**: unpushed, conflict, off pin,
changed.
- **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed,
staged pin, detached HEAD on the pin. They are shown as information only.
- A loading error is shown as `error` (Danger) in the UI; it is not an issue
kind.
### Levels
| Level | Meaning | Presentation |
|---|---|---|
| `LevelQuiet` | Nothing to do | Whole row muted, no word |
| `LevelWarn` | Look at it | Yellow word |
| `LevelDanger` | Act now | Red bold word |
### Hidden issues
`Submodule.HiddenIssues()` counts the issues of **all** loaded descendants
and returns the most severe level. A collapsed submodule shows this as `·k`
in that level's color, so a danger can never hide inside a collapsed subtree.
## Resolving a pin to a ref
The pin is displayed as a ref, not a hash. A commit can be on several
branches, on none, or deep in the history, so the git provider resolves it by
this order and records which rule applied in `PinInfo.Source`:
| # | Rule | `RefSource` |
|---|---|---|
| 1 | `branch = …` in `.gitmodules` | `RefFromGitmodules` |
| 2 | Branch currently checked out in the submodule | `RefFromCurrentBranch` |
| 3 | Remote default branch, if it contains the pin | `RefFromDefaultBranch` |
| 4 | Any remote branch containing the pin, preferring the closest tip (`git branch -r --contains`) | `RefFromContains` |
| 5 | A tag (`git describe`) | `RefFromTag` |
| 6 | Short SHA | `RefFromSHA` |
Distances: `git rev-list --left-right --count <pin>...<ref>` gives
`Ahead` (left) and `Behind` (right).
`Pushed` is true if the pin is reachable from any remote ref. A pin that is
ahead of its ref can still be pushed (e.g. on another branch); only
`Pushed == false` is dangerous.
## Distance notation
| Display | Meaning |
|---|---|
| `↓n` | The ref has n commits the pin doesn't have; the pin is behind |
| `↑n` | The pin has n commits the ref doesn't have |
| `↑n↓m` | Diverged |
The arrows follow the convention of `git status`, lazygit and shell prompts.
+145
View File
@@ -0,0 +1,145 @@
# UX guide
## Principles
1. **Attention follows severity.** Healthy submodules are quiet, problems are
loud, dangers are loudest. Users should be able to scan a long list and
only see what needs action.
2. **One word per row.** At most one state word in the tree (`+n` for more);
the detail pane lists everything.
3. **Words over symbols.** States are short words that need no legend.
Symbols are only used where they are conventional (`↑↓`, `▸▾`).
4. **Meaning never depends on color alone** (every state has a word).
5. **Follow the user's terminal theme.** Use ANSI 16 colors for meaning.
## Layout
```
~/coding/my_project main 13 submodules · 1 unpushed · 1 missing · 2 off pin · … fetched 2m ago
docs origin/main │ src/
misc/tools/ │ my_submodule_3
my_submodule_1 origin/main │
my_submodule_2 missing a1b2c3d │ unpushed pin not pushed to any remote
src/ │
my_submodule_1 off pin origin/main │ Ref origin/main ↑1
▸ my_submodule_2 [2] changed ·2 origin/main │ via checked out branch
my_submodule_3 unpushed origin/main ↑1 │ Pinned b7c8d9e Hotfix null pointer
libs/ │ Checkout b7c8d9e Hotfix null pointer
logging origin/main ↓3 │ Tip 6f5e4d3 Release 2.1.0
↑/↓ move • ←/→ collapse/expand • space toggle • u checkout pin • … • ? help • q quit
```
| Area | Height / width |
|---|---|
| Header | 1 line |
| Tree | Remaining width |
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
| Footer | 1 line (more with full help) |
### Tree row
```
indent ▸ name [n] status ref delta
```
| Column | Width | Content |
|---|---|---|
| indent | 2 per depth | |
| toggle | 3 | `▸` collapsed, `▾` expanded, blank for leaves |
| name | fits the widest visible name | name, `[n]` = direct children (collapsed only) |
| status | 14 | issue word, `+n`, `·k` hidden issues; spinner while loading |
| ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved |
| delta | 6 | `↑n`, `↓n`, `↑n↓m` |
- The name column is only as wide as needed, so states stay close to names.
- Directory headers (`src/`, `modules/`) are muted and not selectable.
- Rows without issues: name and ref muted (except the selected row).
### Detail pane
1. Directory (muted) and name (bold)
2. One line per issue: word (colored, 10 columns) + explanation;
`in sync nothing to do` if there are none; `staged` and
`detached HEAD on pin` as information
3. `Ref` with distance and `via <rule>`
4. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it
differs from the pin
5. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list
6. `Children` (count and issues below), `URL`
### Header
`path branch ↑↓ · n submodules · <count> <issue>… · n pin staged · n loading · fetched <age>`
Issue counts are ordered by severity (unpushed, conflict, missing, off pin,
changed), each in its level color. Zero counts are omitted.
## Colors
All colors are defined in `internal/tui/theme/theme.go`. Never hard-code
colors in components.
| Role | Color | Used for |
|---|---|---|
| Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin |
| Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA |
| Info | ANSI `4` blue | `staged` |
| Primary | ANSI `6` cyan | branch names, commit hashes, spinner |
| Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels |
| Text | terminal default | normal text |
| Border | ANSI `8` grey | pane and help separators |
| Bar | adaptive 254 (light) / 235 (dark) | header background |
| Selected | adaptive 253 (light) / 237 (dark) | selected row background |
- Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`.
The background is detected once in `main` before Bubble Tea starts.
- Styles that render on a background (header, selected row) take a `base`
style (`LevelStyle(level, base)`, `Delta(pin, base)`), so inner segments
keep the background.
## Wording
| Word | Detail pane text |
|---|---|
| `unpushed` | pin not pushed to any remote |
| `conflict` | merge conflict |
| `missing` | not initialized / directory missing |
| `off pin` | checkout differs from pin |
| `changed` | uncommitted changes / untracked files |
| `staged` | pin change staged, not committed |
| `in sync` | nothing to do |
## Navigation
| Key | Behavior |
|---|---|
| `↑` / `k`, `↓` / `j` | Previous / next submodule; directory headers are skipped |
| `space` | Toggle expand/collapse of the selected submodule |
| `→` / `l` | Collapsed with children: expand and select the first child. Expanded: select the first child. Leaf: nothing |
| `←` / `h` | Expanded: collapse it (stays selected). Otherwise: collapse the parent and select it. Top level: nothing |
| `ctrl+→` | Expand all; then follow the first child from the selection down to a submodule without children |
| `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
| `home` / `g`, `end` / `G` | First / last submodule |
| `pgup`, `pgdn` | Move by one screen |
| `?` | Toggle full help |
| `q`, `ctrl+c` | Quit |
| `u`, `b`, `f`, `/`, `!` | Planned actions (see requirements) |
"First child" always means the first child **as displayed** (children are
grouped by directory).
Collapsing an ancestor of the selection moves the selection to that ancestor.
The view scrolls so the selection is always visible; when the first submodule
is selected, headers above it are shown too.
### Mouse
- Click a row: select it.
- Click `▸` / `▾`: select and toggle.
- Scroll wheel: move the selection.
## Accessibility and terminals
- `SUBMARINE_ASCII=1`: ASCII symbols (`^ v > v . *`) instead of Unicode.
- Some terminals or multiplexers capture `ctrl+←/→`; letter aliases can be
added if needed.