From c194fa91ae0fac799d845e8dd0469263ccb6d8c8 Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Fri, 26 Jun 2026 17:58:31 -0500 Subject: [PATCH 1/4] docs(tui-personality): SDD change artifacts (proposal, spec, design, tasks) --- openspec/changes/tui-personality/design.md | 121 ++++++ openspec/changes/tui-personality/explore.md | 383 ++++++++++++++++++ openspec/changes/tui-personality/proposal.md | 86 ++++ .../specs/tui-personality/spec.md | 173 ++++++++ openspec/changes/tui-personality/tasks.md | 70 ++++ 5 files changed, 833 insertions(+) create mode 100644 openspec/changes/tui-personality/design.md create mode 100644 openspec/changes/tui-personality/explore.md create mode 100644 openspec/changes/tui-personality/proposal.md create mode 100644 openspec/changes/tui-personality/specs/tui-personality/spec.md create mode 100644 openspec/changes/tui-personality/tasks.md diff --git a/openspec/changes/tui-personality/design.md b/openspec/changes/tui-personality/design.md new file mode 100644 index 0000000..cc49fd6 --- /dev/null +++ b/openspec/changes/tui-personality/design.md @@ -0,0 +1,121 @@ +# Design: tui-personality + +## Technical Approach + +Additive, render-only personality layer on the existing bak-cli TUI — no architectural refactor, no new dependencies (bubbles/lipgloss v2 already in go.mod). Bubbletea v2 moved program options to declarative `tea.View` fields, so every gap is a field assignment in an existing `View()` returning `tea.View`, consistent with the existing `v.AltScreen = true` in `model.go:619`. All new logic is testable as pure `Update`/`View` functions per AGENTS.md TUI rules; no `Program.Run` in unit tests. + +Maps to specs: `tui-personality` (F1–F7), `tui-interactive-preview` (F5 surface), `restore-flow` delta (F5 routing), `wizard-flow` delta (F8 paste). + +## Architecture Decisions + +### Decision: Window title via View field, not command + +| Option | Tradeoff | Decision | +|--------|----------|----------| +| `tea.SetWindowTitle(...)` cmd in `handleScreenChange` | **Rejected: v1 API, removed in v2.0.7** — Context7 v2 upgrade guide: "replaced by setting the `WindowTitle` field on the View." Won't compile. | ✗ | +| `v.WindowTitle = titleForScreen(m.screen)` in `View()` | Pure read at render time; same frame as content; no command plumbing; matches `v.AltScreen` pattern. | ✓ | + +**Rationale**: Field assignment is the v2-correct primitive, requires zero cmd state, and stays consistent with existing code. `screenChangeMsg` already mutates `m.screen`; `View()` recomputes the title each render from current state. + +### Decision: Single shared spinner for step indicators + +**Choice**: Reuse `ProgressModel.spinner` (already ticking) as the source for the running-step row. +**Alternatives**: per-step spinner.Model (TickMsg storm risk), separate health spinner. +**Rationale**: Progress already batches `m.spinner.Tick` + `pgCmd`. Health has no spinner today — add one `spinner.Model` field there with `Init()` returning `m.spinner.Tick`. Avoids double-tick contention. + +### Decision: Viewport embedded in RestoreModel, not a shared component + +**Choice**: `RestoreModel.viewport viewport.Model` field. +**Alternatives**: `internal/tui/components/viewport.go` shared wrapper. +**Rationale**: AGENTS.md DRY — no other screen needs a scrollable viewport yet. Premature shared component adds coupling without payoff. If a second consumer appears, extract then. + +### Decision: Status bar / empty state as stateless render fns + +**Choice**: `components/statusbar.go`, `components/empty_state.go` — pure functions matching existing `RenderMenu`/`RenderHelp`/`RenderCheckbox` pattern. +**Alternatives**: sub-models with their own `Init/Update/View`. +**Rationale**: Both are pure renders (no message handling). Matches established component category in the codebase and AGENTS.md grouping. + +### Decision: Mouse scroll guard via `search.IsActive()` + +**Choice**: In `DashboardModel.Update`, `case tea.MouseMsg:` returns `(m, nil)` unmodified when `m.search.IsActive()`. +**Rationale**: Persona rule — familiar feel bug (wheel steals focus while typing in search). Cheap guard, easy to forget, requires an explicit test. + +## Data Flow + +``` +handleKey ──► screenChangeMsg ──► m.screen = X + │ +View() ──► renderContent() │ + ├─ renderScreen (sub-model View) │ (sub-model owns its View fields: + │ └ sets MouseMode, Cursor… │ MouseModeCellMotion, viewport content) + ├─ toast overlay │ + └─ components.RenderStatusBar(w,v,p,path) ← reads m.screen/m.deps +View() ──► v.WindowTitle = titleForScreen(m.screen) (per REQ-TP-001) +View() ──► v.AltScreen = true (existing) + +restoreDryRunResultMsg ──► viewport.SetContent(diff) ──► restoreStateDryRun + └ tea.KeyPressMsg (j/k/PgUp/PgDn/g/G/↑↓) ──► viewport.Update ──► new viewport + └ q ──► restoreStateList +``` + +## File Changes + +| File | Action | Description | +|------|--------|-------------| +| `internal/tui/model.go` | Modify | Add `titleForScreen(m) string` pure helper; set `v.WindowTitle` in `View()`; call `components.RenderStatusBar(...)` in `renderContent()`; expose `m.deps.Version/Preset/BackupPath` for the bar | +| `internal/tui/components/statusbar.go` | Create | Stateless `RenderStatusBar(width, version, preset, path) string` | +| `internal/tui/components/statusbar_test.go` | Create | Table-driven: wide/narrow width, truncation, hidden <40 | +| `internal/tui/components/empty_state.go` | Create | Stateless `RenderEmptyState(icon, msg, hint) string` | +| `internal/tui/components/empty_state_test.go` | Create | Table-driven render assertions | +| `internal/tui/styles/screens.go` | Modify | Add pkg-level `StatusBarStyle`, `EmptyStateIconStyle`, `EmptyStateMsgStyle`, `EmptyStateHintStyle` (Rose Pine semantic colors) | +| `internal/tui/styles/logo.go` | Modify | Replace 5 fixed `Foreground()` styles with `lipgloss.Blend1D(len(lines), ColorLove,ColorGold,ColorRose,ColorPine,ColorLavender)`; no-color profile fallback to plain text | +| `internal/tui/styles/logo_test.go` | Modify/Add | Assert `len(grad) == len(lines)`; no-color branch yields uncolored logo | +| `internal/tui/screens/progress.go` | Modify | In `stepIndicator(StepRunning)`, return `m.spinner.View()` instead of literal `"⠹"` (delete `progressStepRunningIndicator` const usage for running rows) | +| `internal/tui/screens/progress_test.go` | Modify | Advance spinner N ticks, assert running-row output contains current frame | +| `internal/tui/screens/health.go` | Modify | Add `spinner spinner.Model` field; `Init()` returns `m.spinner.Tick`; `Update` propagates `spinner.TickMsg`; running step uses `m.spinner.View()` | +| `internal/tui/screens/dashboard.go` | Modify | Set `v.MouseMode = tea.MouseModeCellMotion` in `View()`; add `case tea.MouseWheelMsg` (forward to `table.Update`) and `case tea.MouseClickMsg` (set cursor by Y); guard `m.search.IsActive()`; empty branch calls `RenderEmptyState` | +| `internal/tui/screens/restore.go` | Modify | Embed `viewport viewport.Model`; size on `WindowSizeMsg`; `restoreDryRunResultMsg` → `m.viewport.SetContent(output)`; in `restoreStateDryRun` forward scroll keys to `viewport.Update`; `renderDryRun` writes `m.viewport.View()`; set `v.MouseMode`; empty branch + error branch use `RenderEmptyState` | +| `internal/tui/screens/restore_test.go` | Modify | viewport sizing, `PgDn`/`PgUp`/`g`/`G` scroll assertions, `q` returns to list, `MouseWheelMsg{Y:-1}` scrolls; `MouseMode` set | +| `internal/tui/screens/dashboard_test.go` | Modify | `MouseWheelMsg`/`MouseClickMsg` cases; mouse suppressed when `search.IsActive()` | +| `internal/tui/screens/cloud.go` | Modify | No-provider branch uses `RenderEmptyState` instead of bare string; surface error branch (pre-existing bug flagged in explore.md) | +| `internal/tui/screens/wizard.go` | Modify | Add `case tea.PasteMsg:` to active textinput Update paths; append `msg.Content`; keep `DisableBracketedPasteMode = false` | +| `internal/tui/screens/wizard_test.go` | Modify | Send `tea.PasteMsg{Content: "work-laptop"}`, assert input updated | +| `internal/tui/model_test.go` | Modify | Table-driven `View().WindowTitle` assertions per `m.screen` (Menu, Wizard, Restore w/ id, Progress w/ step counter) | + +## Interfaces / Contracts + +```go +// Pure, exported for table-driven tests, lives in components package. +func RenderStatusBar(width int, version, preset, path string) string +func RenderEmptyState(icon, message, hint string) string + +// Pure title mapper in package tui (root). +func titleForScreen(s screen) string // "bak — Main Menu", "bak — Wizard", ... + +// Spinner on health (mirrors ProgressModel). +type HealthModel struct { + // …existing… + spinner spinner.Model +} +``` + +No new interfaces for the dispatch layer: the existing `subModel` interface and `subEntries` map handle the new fields transparently — `View()` returning a `tea.View` with extra fields doesn't change routing. + +## Testing Strategy + +| Layer | What to Test | Approach | +|-------|-------------|----------| +| Unit | `titleForScreen`, `RenderStatusBar`, `RenderEmptyState`, `RenderLogo` gradient/no-color, step indicator frame, viewport scroll keys, mouse wheel/click suppress-on-search, `PasteMsg` append — all pure `Update`/`View` | Table-driven `go test`; construct msgs literally (`tea.MouseWheelMsg{Y:-1}`, `tea.PasteMsg{Content:"…"}`, `restoreDryRunResultMsg{output:DryRunOutput}`); assert on `View().Content` / `View().WindowTitle` / `View().MouseMode`. No `Program.Run`. | +| Unit | `View().WindowTitle` per active screen + step counter on `ScreenProgress` | Construct `Model{screen: X}`, call `View()`, assert field | +| Integration | End-to-end restore dry-run shows viewport content; dashboard mouse wheel moves table cursor | Existing `internal/tui/` integration test pattern (real `RestoreAction` stub via deps) | +| Coverage | ≥80% per `internal/tui/` package per AGENTS.md | `go test -cover ./internal/tui/...`; gap-fill before apply exit | + +## Migration / Rollout + +No migration. All changes are additive render-only. Per proposal PR split Tier 1→2→3 — each tier is an independent PR, revert by PR. + +## Open Questions + +- [ ] Exact `lipgloss.Blend1D` signature in `charm.land/lipgloss/v2 v2.0.3` (explore.md says Context7-verified; design assumes `Blend1D(n int, stops ...lipgloss.Color)`). **Verify in apply before writing the logo.go change.** If signature differs, fall back to per-line `Foreground()` with profile-gated color (degraded but spec-compliant gradient). +- [ ] Does `tea.View` expose `MouseMode` as a settable `MouseMode` enum field? Context7 confirms yes (`v.MouseMode = tea.MouseModeCellMotion`). Confirm in apply against the actual struct in v2.0.7. +- [ ] Where to source status-bar `preset` and `backup path` for the version/preset/path segments — `m.deps` needs accessors (Version exists; preset/path may need new `Deps` fields or a config loader call). Decide in tasks phase. \ No newline at end of file diff --git a/openspec/changes/tui-personality/explore.md b/openspec/changes/tui-personality/explore.md new file mode 100644 index 0000000..144aba2 --- /dev/null +++ b/openspec/changes/tui-personality/explore.md @@ -0,0 +1,383 @@ +# Exploration: tui-personality + +**Change**: tui-personality +**Mode**: openspec, interactive, Strict TDD ACTIVE +**Date**: 2026-06-26 +**Explorer**: sdd-explore (fresh-context, evidence-based) +**Skills loaded**: sdd-explore, bubbletea, bubbletea-code-review, building-glamorous-tuis (partial), golang-pro +**Context7 docs queried**: bubbletea/v2 upgrade guide (View fields, removed commands), bubbles/v2 (spinner, progress, table), lipgloss/v2 (Blend1D/Blend2D gradients) +**Go**: 1.26.4 via mise + +## Executive Summary + +bak-cli's TUI already sits higher than the brief assumed. I read every non-test TUI file (43 files) and verified imports: it uses **4 of the ~15 Charm feature families** today — `bubbles/textinput` (search), `bubbles/table` (dashboard), `bubbles/spinner` + `bubbles/progress` (progress screen), plus alt screen (already enabled via `v.AltScreen = true` in `model.go:621`). The prompt's "only ~2 families / no spinner / no progress / no alt screen" audit is **stale and wrong**. TheRose Pine palette is genuinely solid but corporations-conservative in *application*: static step indicators, no window title, no mouse, no viewport for long content (dry-run diffs wrap/clutter), no logo animation, no microinteractions, no global status bar. This change targets the *personality deltas* that actually move the needle for a backup/restore CLI: **window title + status bar + viewport-driven preview + spinning step indicators + mouse list nav + a startup logo flourish + styled empty states**, not a feature-bloat catalogue. + +Personality is currently ~4/10. This plan brings it to **8/10** by adding 9 concrete affordances (5 Tier-1, 3 Tier-2, 1 Tier-3), each tied to a real bak-cli use case, each testable as a pure Update/View function per AGENTS.md TUI rules. + +**Ready for proposal: YES.** + +--- + +## Section 1 — Current TUI State Audit (evidence-based) + +### File inventory (non-test line counts) + +| Area | Files | Largest | Notes | +|------|-------|---------|-------| +| Root model | `model.go` (897), `dispatch.go` (30), `keys.go` (25), `deps.go` (115) | 897 | **subModel dispatch map already landed** (qa-refactor-analysis, `subEntries` map + `forwardTo`). NOT the 856-line god-function the brief feared. | +| Screens | 13 `.go` files | `wizard.go` (341), `restore.go` (337), `profiles.go` (303), `dashboard.go` (192), `health.go` (178), `progress.go` (201), `cloud.go` (170), `settings.go` (168), `welcome.go` (57), `menu.go` (55), `shortcuts.go` (86), `goconst_constants.go` (16) | — | Each screen implements its own `Init/Update/View` + `WindowSizeMsg`. | +| Components | 7 `.go` files | `modal.go` (158), `search.go` (120), `toast.go` (72), `menu.go` (47), `help.go` (31), `checkbox.go` (27), `radio.go` (27) | — | All **stateless render fns** except `modal`, `search`, `toast` (own sub-models). | +| Styles | 5 `.go` files | `screens.go` (106), `styles.go` (108), `theme.go` (41), `logo.go` (55), `frame.go` (11) | — | All package-level `var` (AGENTS.md compliant). | + +### Screens that exist +`ScreenMenu`, `ScreenWelcome`, `ScreenDashboard`, `ScreenProgress`, `ScreenSettings`, `ScreenCloud`, `ScreenShortcuts`, `ScreenHealth`, `ScreenRestore`, `ScreenProfiles`. (`ScreenWelcome`, `ScreenMenu`, `ScreenShortcuts` are stateless renderers; the other 7 own sub-models dispatched via `subEntries`.) + +### Components that exist +`RenderMenu`, `RenderCheckbox`, `RenderRadio`, `RenderHelp`, `Search` (sub-model, wraps `textinput`), `Modal` (sub-model, centered bordered), `Toast` (sub-model, auto-hide). + +### Styles that exist +Rose Pine semantic colors (Base/Surface/Overlay/Muted/Subtle/Text/Love/Gold/Rose/Pine/Lavender) in `theme.go`. Shared styles (`Title/Heading/Selected/Frame/Panel/Help/Checked/Unchecked/RadioSelected/Toast/ScreenTitle/Search`) + per-screen style blocks (dashboard, progress, cloud) all package-scope. Logo: 5-line ASCII "bak" with a **static 5-band Rose Pine gradient** (Love→Gold→Rose→Pine→Lavender per line). Frame helper wraps content in `DoubleBorder`. + +### Charm feature usage today (corrected from the brief) +| Feature | In use? | Where | Quality | +|--------|---------|-------|---------| +| `bubbles/textinput` | ✅ | `components/search.go` | search box only | +| `bubbles/table` | ✅ | `screens/dashboard.go` | hardcoded width 76/height 20, resizes on WindowSizeMsg | +| `bubbles/spinner` | ✅ | `screens/progress.go` | spinner ticks, but **only the standalone spinner row animates** | +| `bubbles/progress` | ✅ | `screens/progress.go` | bar with `WithDefaultBlend`, 40-wide | +| alt screen | ✅ | `model.go:621` `v.AltScreen = true` | **already enabled** | +| `bubbles/list` | ❌ | — | restore/dashboard use hand-rolled cursor lists | +| `bubbles/viewport` | ❌ | — | dry-run diff and shortcuts are raw multi-line strings; long content wraps/clutters | +| `bubbles/key` | ❌ | — | help is hand-rolled `HelpKey{Key,Desc}` table | +| `glamour` | ❌ | — | help is plain text | +| `harmonica` | ❌ (indirect dep only) | go.mod has `charmbracelet/harmonica v0.2.0` indirect | unused | +| `lipgloss` gradients | ⚠️ partial | `logo.go` uses 5 fixed styles, **not the `Blend1D` gradient API** | no smooth/animated gradient | +| `tea.View.WindowTitle` | ❌ | — | terminal title never set | +| `tea.View.MouseMode` | ❌ | — | no mouse | +| `tea.View.ForegroundColor/BackgroundColor` | ❌ | — | no terminal theme detection; Rose Pine always hardcoded | +| paste / bracketed-paste | ❌ | — | wizard `StepName` reads char-by-char, no paste support | + +### The current user experience (why it's 4/10) +1. **No global context bar.** Once you leave the menu there's no persistent reminder of app name, version, active screen, or current operation. You're dropped into a bare heading line. +2. **Static "running" indicators.** `progress.go:50` and `health.go:175` render the running step as the *literal string* `"⠹"` — it never rotates. The live `spinner.Model` ticks but only in a disconnected row. A backup looks frozen at the per-step level even though it isn't. +3. **No terminal title.** When minimized or in a tab strip, the terminal shows whatever the shell set — no "bak — Backup 3/7". +4. **Dry-run diff is unscrollable.** `restore.go:305 renderDryRun` dumps `m.DryRunOutput` verbatim. A real restore diff for a config-rich machine is dozens of lines; it wraps and pushes the help bar off-screen. +5. **No mouse.** Lists (restore picker, dashboard table) are keyboard-only. Mouse scroll is the expected affordance in 2026 terminals. +6. **No startup flourish.** Logo renders instantly, static. A tool that markets "pack your setup, move anywhere" deserves a one-time reveal. +7. **Empty states are bland.** "No backups found" / "(no dry-run available)" are dead strings — a chance for personality (recoverable empty state with a CTA). +8. **No screen-transition feedback.** `screenChangeMsg` swaps content in a single frame — jarring on slow terminals. + +### Correctness risks found during read (not personality, but worth flagging) +- `dashboard.go:65-72` table created with `WithWidth(76)/WithHeight(20)` **before** WindowSizeMsg arrives; first paint on a 120-col terminal is narrow until resize. Low-impact, fixable alongside this change. +- `cloud.go:84-93` View renders `RenderCloudStatus(CloudInfo{}, …)` in **both** the no-provider and error branches — an error currently shows the empty-state string, hiding the error. Pre-existing bug; this change's status bar can surface it. + +### Affected Areas +- `internal/tui/model.go` — add global status bar field, window title, transition state, mouse mode on View. +- `internal/tui/screens/progress.go` + `health.go` — drive step indicators off the live spinner tick, kill static `"⠹"`. +- `internal/tui/screens/restore.go` — replace raw `DryRunOutput` dump with a `viewport.Model`. +- `internal/tui/screens/dashboard.go` + `restore.go` — opt into `MouseModeCellMotion` on the screen View; route `tea.MouseWheelMsg` to the table/list. +- `internal/tui/styles/logo.go` — switch from 5 fixed `Foreground()` calls to `lipgloss.Blend1D` + optional startup reveal frames. +- `internal/tui/components/` — new `statusbar` component (stateless render) + `viewport` wrapper component or direct screen use. +- `go.mod` — `bubbles/v2` already present; `glamour` NOT required (viewport plain text suffices for diffs; markdown is a Tier-5 maybe-have). + +--- + +## Section 2 — Bubbletea v2 Feature Gap Analysis (Context7-verified) + +v2 shifts program options/commands to **declarative `tea.View` fields** (`AltScreen`, `MouseMode`, `WindowTitle`, `ForegroundColor/BackgroundColor`, `Cursor`, `ProgressBar`, `ReportFocus`, `DisableBracketedPasteMode`). bak-cli already returns `tea.View` from every model and sets `AltScreen` — so all gaps below are *field assignments in existing `View()` returns*, not new program options. + +| # | Feature | What it does (Context7) | Concrete bak-cli use case | Where it lives | Complexity | Personality impact (1-5) | +|---|---------|------------------------|--------------------------|----------------|------------|--------------------------| +| 1 | `tea.View.WindowTitle` | Set terminal tab title declaratively | "bak — Menu" / "bak — Backup 3/7" / "bak — Restore:abc1234 (dry-run)" | `model.go View()` (per-screen) + each sub-model View | **S** | **5** | +| 2 | Spinning step indicators (`bubbles/spinner` reuse) | One spinner drives animated per-row indicator frames instead of static glyph | progress + health running rows reflect actual rotation | `progress.go`, `health.go` View() | S | 5 | +| 3 | `bubbles/viewport` | Scrollable, cursor-aware content region with `PgUp/PgDn/g/G` defaults | dry-run diff preview (restore) + help/shortcuts on tall content | `restore.go renderDryRun`, possibly `shortcuts.go` | M | 4 | +| 4 | `tea.View.MouseMode` = `MouseModeCellMotion` | Wheel-scroll + click messages | scroll dashboard table, scroll dry-run viewport, click restore list rows | dashboard/restore View() + Update MouseWheelMsg routing | M | 4 | +| 5 | Global status bar (new `components/statusbar`) | Persistent one-line bar: logo glyph + version + active screen + running op | every screen sees context; replaces mid-View reconstruction | `model.go renderContent`, new `components/statusbar.go` | S | 5 | +| 6 | `lipgloss.Blend1D` gradient | Smooth multi-stop color gradient (logo line, progress bar border, success banner) | gradient logo + "Complete!" banner in Rose Pine sweep | `styles/logo.go`, `progress.go` View | S | 4 | +| 7 | Startup logo reveal (spinner-driven frames) | Animate logo color block in on first paint | one-time flourish on `ScreenWelcome`/first menu render | `welcome.go`/`menu.go` via a short tea.Tick sequence | M | 4 | +| 8 | Styled empty + error states | Branded empty-state panel with recovery CTA instead of bare string | "No backups yet — press `b` to create one" (dashboard/restore/cloud) | dashboard, restore, cloud empty branches | S | 4 | +| 9 | `tea.View.ForegroundColor/BackgroundColor` | Detect terminal's fg/bg to decide light-vs-dark fallback | users on light terminals currently get Rose Pine dark-on-near-black → unreadable text; detect and adapt | `model.go View()` (one-time detection cmd) | M | 3 | +| 10 | `tea.View.ProgressBar` | Native terminal OSC9 progress bar in the tab badge | when minimized, terminal tab shows backup % | `model.go View()` while `ScreenProgress` running | S | 3 | +| 11 | `harmonica` spring animation | Physics smoothing for value transitions | smooth percent rise on progress bar; smooth reveal | `progress.go` (wrap progress.SetPercent in spring) | M | 2 | +| 12 | `bubbles/list` | Filterable, paginated list with built-in help | replace hand-rolled restore picker (gains `/`-filter, pagination, mouse, help) | `restore.go` list state | L | 4 (but restore picker already works; high churn) | +| 13 | `bubbles/key` + help.KeyMap | Structured bindings + generated help | auto-consistent shortcuts overlay vs hand-maintained `shortcuts.go` | `shortcuts.go`, per-screen `Update` | M | 2 (consistency, not flash) | +| 14 | `glamour` markdown rendering | Render markdown with syntax/typography | help text / changelog preview | `shortcuts.go` | M (new dep) | 2 | +| 15 | Paste (`DisableBracketedPasteMode=false`) | Multi-char paste into textinput | wizard `StepName` profile name, search box | `wizard.go`, `search.go` | S | 2 | +| 16 | `bubbles/textinput` enhancements | `EchoPassword`, `CharLimit`, `Placeholder` | wizard name field placeholder + length cap | `wizard.go` (use textinput instead of hand-rolled NameInput) | S | 2 | + +### Explicitly NOT proposed (no concrete bak-cli value) +- `tea.View.ReportFocus` — no focus-sensitive widgets that benefit. +- `tea.View.KeyboardEnhancements` — no need for Kitty/WezTerm extended keys; vanilla keys cover all bak bindings. +- Full screen-transition animation system (fade/slide between screens) — high effort, fights the alt-screen + fast-swap model; a *contextual* status-bar transition (instant content + persistent bar) is the better UX. Listed as Tier-4 skip. + +--- + +## Section 3 — Personality Design Vision + +**Brand voice:** "Calm competence with a quiet flourish." Bak-cli moves your *coding setup* — precious, invisible infrastructure. The TUI should feel like a well-made tool, not a toy: precise Rose Pine color, generous spacing, no gratuitous animation, but **one** moment of delight on startup and **continuous** subtle life during operations. Think `lazygit`'s information density crossed with `charmbracelet/gum`'s polish — not a demo reel. + +**Microinteractions:** +- **Startup:** logo gradient sweeps in band-by-band over ~600ms (4 tea.Tick frames), only on the first `ScreenMenu`/`ScreenWelcome` paint, never again in the session. Skippable (any keypress completes it instantly). +- **Operation life:** the step row that's "running" shows the spinner's current frame (`spinner.View()`), so a backup visibly breathes instead of freezing at `"⠹"`. On `StepDone` the row flips to a colored `✓` with a brief fade handled by style, not physics. +- **Completion:** a single "Complete!" line with a Rose Pine `Blend1D` gradient underline — a contained celebration, no finale. +- **Errors:** instead of `Error: ` dead text, a bordered Love-colored panel with the failed step, a one-line cause, and the CTA `[r] retry [q] back`. Character via clarity, not snark. + +**Transitions:** instant content swap + the new persistent status bar updates its "active screen" segment in the same frame. The bar provides continuity so the swap doesn't feel jarring — *no* fade/slide, which would add latency to a tool users run dozens of times. + +**Loading states:** the existing spinner, but (a) reused as the per-step indicator (Section 2 #2) and (b) paired with rotating status verbs — "Scanning adapters…", "Hashing files…", "Writing manifest…" — drawn from the step name the engine already sends via `ProgressUpdate.Step`. No new strings to author; surface what's already transmitted. + +**The "wow" moment:** launching `bak` for the first time and watching the gradient logo resolve + seeing the terminal tab read `bak — Menu`, then `bak — Backup 4/7` while it runs and you switch tabs to do other work. *Situational* delight: the tool respects your context. + +--- + +## Section 4 — Architecture Compatibility + +1. **subModel dispatch (already landed).** `model.go` routes via `subEntries` map + `forwardTo`. New per-screen View-field concerns (WindowTitle, MouseMode) fit cleanly: each sub-model's existing `View()` already returns `tea.View`, so we add field assignments there. The root `View()` can layer a *global* window title as the default and let sub-models override via a returned title. No new dispatch abstraction needed. +2. **Further extraction needed?** `model.go` is 897 lines but ~40% is already-extracted init helpers (`initDashboard/Progress/…`). The router is comfortable. We should extract a small `renderStatusBar(m)` helper (S) and a `currentWindowTitle(m) string` helper (S), but **no** structural refactor is a prerequisite. This change is additive. +3. **Where new components live.** + - Reusable, stateless → `internal/tui/components/` (new `statusbar.go`). + - Viewport lives *inside* the restore screen → `internal/tui/screens/restore.go` (screen-owned `viewport.Model` field), not a shared component (no other screen needs it yet — avoid premature abstraction per AGENTS.md DRY rules). + - A shared `animate`/`startupReveal` concern, if added, belongs in `internal/tui/styles/logo.go` (render logic) or a tiny `internal/tui/components/reveal.go` if it grows state. +4. **Command structure.** Spinner already returns `spinner.Tick`; progress re-batches `tea.Batch(m.spinner.Tick, pgCmd)`. New global concerns add at most: a one-time `tea.Tick` for the startup reveal (returns `revealFrameMsg`), and a theme-detection `tea.Cmd` (returns `themeDetectedMsg{isDark bool}`). Both are standard `tea.Cmd`-returning helpers, no restructuring of the Update loop. +5. **Alt screen + WindowSizeMsg.** Already handled (`m.width/height` set on `WindowSizeMsg`, forwarded to active sub-model). Enabling `MouseMode` on a View does **not** change `WindowSizeMsg` semantics — mouse msgs (`tea.MouseWheelMsg`, `tea.MouseClickMsg`) become additional `tea.Msg` cases in the relevant screen `Update`. Root `Update` already forwards unhandled msgs to the active sub-model via `forwardTo`, so mouse routing is *mostly* free — each screen just adds a `case tea.MouseWheelMsg`. + +--- + +## Section 5 — Implementation Priority (tiered) + +### Tier 1 — High value, Low effort (do first) +- **F1 Window title** (#1) — 1 field per View, ~5 lines/screen. S. +- **F2 Spinning step indicators** (#2) — replace static glyph with `m.spinner.View()`; already ticking. S. +- **F5 Global status bar** (#5) — new stateless `components/statusbar.go` + wire into `renderContent`. S. +- **F6 Gradient logo** (#6) — swap `logo.go` 5-style array for `Blend1D(len(lines), Love,Gold,Rose,Pine,Lavender)`. S. +- **F10 Native terminal progress bar** (#10) — `v.ProgressBar` field while on ScreenProgress. S. + +### Tier 2 — High value, Medium effort +- **F3 Viewport for dry-run diff** (#3) — `restore.go`: embed `viewport.Model`, size on WindowSizeMsg, wire keys. M. +- **F4 Mouse scroll/click** (#4) — enable `MouseModeCellMotion` on dashboard/restore Views, add `MouseWheelMsg` cases. M. +- **F8 Styled empty/error states** (#8) — branded panels with CTA; refactor empty branches in dashboard/restore/cloud. M (touches 3 screens). + +### Tier 3 — Medium value, Low effort +- **F15 Paste support** (#15) — set `DisableBracketedPasteMode=false` (default) and ensure wizard/search consume `tea.PasteMsg`. S. +- **F9 Terminal theme detection** (#9) — one `tea.Cmd` reads default fg color; choose Rose Pine vs a light-friendly palette. M. +- **F16 textinput enhancements** (#16) — wizard `StepName` → `textinput.Model` with `Placeholder`/`CharLimit`. S. + +### Tier 4 — High value, High effort (skip unless time) +- **F7 Startup logo reveal** (#7) — delightful but adds a reveal state machine + timing. Defer to a follow-up change OR ship a *static* gradient (F6) now and animate later. +- **F11 harmonica spring** (#11) — smooth progress is nice but bubbles/progress already animates; marginal. Skip. +- **F12 bubbles/list** (#12) — high churn, restore picker works. Defer. +- **F13 bubbles/key help** (#13), **F14 glamour** (#14) — consistency/convenience, not personality. Defer. + +**Recommended scope for *this* change:** Tier 1 + Tier 2 + F15. That's **9 affordances**, all with concrete use cases, all testable. F7/F9/F16 optional stretch. + +--- + +## Section 6 — Risk Assessment + +1. **Alt-screen tests:** already enabled + `Program.Run()` is never unit-tested (AGENTS.md). Adding `WindowTitle`/`MouseMode`/`ProgressBar` View fields is pure-data on the View struct → **no existing test breakage**; tests assert on `View().Content` (string) and now may also assert `View().WindowTitle`. Safe. +2. **Mouse vs keyboard conflict:** none inherent — mouse msgs are a distinct `tea.Msg` type; keyboard handling is untouched. Risk is **feel**: ensure mouse wheel doesn't scroll when search input is focused (add a `if m.search.IsActive()` guard in dashboard's `MouseWheelMsg` case). Low. +3. **Spinner/progress command complexity:** the Update loop already batches `spinner.Tick` + `progress.SetPercent`. F2 reuses the same `m.spinner` — one source of ticks, no second spinner. Risk is a `TickMsg` storm if two spinners run; mitigated by sharing the single `spinner.Model`. Low. +4. **Gradient rendering across terminals:** `lipgloss.Blend1D` emits truecolor/256color/ansi based on detected profile (lipgloss auto-detects via `colorprofile`, already an indirect dep). Windows Terminal + modern macOS + Linux all support truecolor. Fallback to nearest ANSI is automatic. Risk: terminals with no-color show no gradient (acceptable — degrades to monochrome logo, still readable). Low. +5. **Coverage impact:** every new component and every modified `View`/`Update` needs ≥80% coverage per AGENTS.md. F3 (viewport) and F4 (mouse) add the most test surface. Mitigation: TDD Update/View as pure functions (the established pattern); `MouseWheelMsg` is just a `tea.Msg` struct, trivially constructible in tests. F5 statusbar is a stateless render fn → table-driven test like `help.go`/`menu.go`. + +### New test surface (TDD-friendly, all pure functions) +- `components/statusbar_test.go` — table-driven render assertions (widths, screen labels, running-op text). +- `restore_test.go` — extend for viewport sizing + `PgUp/PgDn` + MouseWheel scrolling (assert `viewport.ScrollPercent`/cursor). +- `dashboard_test.go` + `restore_test.go` — assert `View().MouseMode == tea.MouseModeCellMotion` when enabled. +- `model_test.go` — assert `View().WindowTitle` per active screen; assert native `ProgressBar` set only on ScreenProgress. +- `progress_test.go` + `health_test.go` — assert running-step rendered frame equals `m.spinner.View()` (snapshot the spinner's current frame). +- `logo_test.go` — assert `Blend1D` returns `len(lines)` colors and monochrome fallback when no color. + +--- + +## Section 7 — Dependency Audit + +From `go.mod`: +- `charm.land/bubbles/v2 v2.1.0` ✅ — provides `viewport`, `list`, `key`, `spinner`, `progress`, `table`, `textinput`. **No new bubbles import needed.** +- `charm.land/bubbletea/v2 v2.0.7` ✅ — provides `tea.View` fields (`WindowTitle`, `MouseMode`, `ProgressBar`, `ForegroundColor/BackgroundColor`, `Cursor`, `DisableBracketedPasteMode`). No upgrade needed. +- `charm.land/lipgloss/v2 v2.0.3` ✅ — provides `Blend1D`/`Blend2D` (Context7-verified API). No upgrade. +- `github.com/charmbracelet/harmonica v0.2.0` — already an **indirect** dep (pulled by bubbles); importing it directly for F11 is zero new download. (F11 skipped anyway.) +- `github.com/charmbracelet/glamour` — **NOT** a dep; F14 (glamour) would add one. **F14 deferred** → no new dep in recommended scope. + +**AGENTS.md "prefer stdlib" compliance:** bubbles/bubbletea/lipgloss are already first-class deps; using more of their exported API (viewport, View fields, Blend1D) is *consistent* with existing choices — no new dependency justification required for Tier 1+2. ✅ + +--- + +## Section 8 — Concrete Implementation Plan (for design phase) + +### F1 — Window title (`internal/tui/model.go`, per screen) +```go +// new helper +func (m Model) currentWindowTitle() string { + switch m.screen { + case ScreenProgress: + if m.progress != nil && m.progress.Running() { + cur, tot := m.progress.Stats() // add accessor + return fmt.Sprintf("bak — Backup %d/%d", cur, tot) + } + return "bak — Progress" + case ScreenRestore: + if m.restore != nil && m.restore.SelectedID != "" { + return fmt.Sprintf("bak — Restore:%s", m.restore.SelectedID) + } + return "bak — Restore" + // … one case per screen + } + return "bak" +} +// in View(): +v.WindowTitle = m.currentWindowTitle() +``` +Tests: `model_test.go` table asserting `View().WindowTitle` per `m.screen`. Pure. + +### F2 — Spinning step indicators (`screens/progress.go`, `screens/health.go`) +```go +// progress.go View(): replace static indicator +case StepRunning: + indicator = m.spinner.View() // live rotating frame +// health.go needs its own spinner.Model field (currently shares none); +// add `spinner spinner.Model` to HealthModel, Init() returns m.spinner.Tick, +// Update propagates spinner.TickMsg like progress.go does. +``` +Tests: in `progress_test.go`/`health_test.go`, set `m.spinner` to a known frame (call `m.spinner.Update(spinner.TickMsg{})` N times), assert running-row output contains that frame string. + +### F3 — Viewport for dry-run (`screens/restore.go`) +```go +type RestoreModel struct { + // …existing… + viewport viewport.Model + vpReady bool +} +// NewRestoreModel: m.viewport = viewport.New(viewport.WithWidth(80), viewport.WithHeight(10)) +// WindowSizeMsg: m.viewport.Width = msg.Width-4; m.viewport.Height = msg.Height-8; m.vpReady = true +// restoreDryRunResultMsg: m.viewport.SetContent(m.DryRunOutput) +// Update case tea.KeyPressMsg in restoreStateDryRun: forward j/k/PgUp/PgDn/g/G to m.viewport.Update +// renderDryRun: b.WriteString(m.viewport.View()) +``` +Tests: TDD `restore_test.go` — given DryRunOutput of 50 lines and height 10, pressing `PgDn` advances `viewport.ScrollPercent`; pressing `g` returns to top. + +### F4 — Mouse (`screens/dashboard.go`, `screens/restore.go`) +```go +// in View(): +v := tea.NewView(content) +v.MouseMode = tea.MouseModeCellMotion +return v +// in Update, add: +case tea.MouseWheelMsg: + if m.search.IsActive() { return m, nil } // dashboard guard + newTbl, cmd := m.table.Update(msg) // bubbles table handles wheel + m.table = newTbl + return m, cmd +``` +Tests: assert `View().MouseMode == tea.MouseModeCellMotion`; construct `tea.MouseWheelMsg{Y: -1}` and assert table cursor moved. + +### F5 — Status bar (new `internal/tui/components/statusbar.go`) +```go +// Package-level styles (define in styles/, not here — AGENTS.md). +// stateless render fn: +func RenderStatusBar(version, screenLabel, opLabel string, width int) string +``` +Wire in `model.go renderContent()` above the screen content. Tests: `components/statusbar_test.go`, table-driven (narrow width truncates, running-op shows, labels map per screen). + +### F6 — Gradient logo (`styles/logo.go`) +```go +func RenderLogo(width int) string { + // …guard, split lines… + stops := []lipgloss.Color{ColorLove, ColorGold, ColorRose, ColorPine, ColorLavender} + grad := lipgloss.Blend1D(len(lines), stops...) + // render each line i with lipgloss.NewStyle().Foreground(grad[i]) +} +``` +Tests: `logo_test.go` assert `len(grad) == len(lines)`; assert width<40 returns ""; assert non-empty output contains logo glyphs. + +### F10 — Native progress (`model.go View()` while `ScreenProgress`) +```go +if m.screen == ScreenProgress && m.progress != nil && m.progress.Running() { + v.ProgressBar = m.progress.Percent() // bubbles progress exposes ratio; map to tea's bar value +} +``` +Tests: assert `View().ProgressBar != 0` only on running progress; zero elsewhere. + +### F8 — Styled empty states (`screens/dashboard.go`, `restore.go`, `cloud.go`) +Replace `DashboardEmptyStyle.Render("No backups found")` with a bordered panel + CTA line: +```go +styles.EmptyStatePanelStyle.Render(fmt.Sprintf("No backups yet\n\n press %s to create one", styles.SelectedStyle.Render("b"))) +``` +Add `EmptyStatePanelStyle` + `EmptyStateCTAStyle` package-level vars to `styles/screens.go`. Tests: assert empty-state output contains the CTA key glyph. + +### F15 — Paste (`wizard.go` `StepName`, `search.go`) +```go +// wizard.go Update: add +case tea.PasteMsg: + m.NameInput += msg.Text + return m, nil +// View: v.DisableBracketedPasteMode = false (default, but explicit) +``` +Tests: send `tea.PasteMsg{Text: "work-laptop"}`, assert `NameInput == "work-laptop"`. + +--- + +## Section 9 — AGENTS.md Compliance Check + +| Rule | How this change complies | +|------|--------------------------| +| Package-level `var` lipgloss styles (AGENTS.md §styles) | New `EmptyStatePanelStyle`, statusbar styles, gradient style lives in `styles/` package scope. No inline `lipgloss.NewStyle()` in any `View()`. | +| New components have Init/Update/View or are stateless render fns | statusbar = stateless render fn (like `help`/`menu`); viewport is a screen-owned sub-model (already has Update/View via bubbles). | +| `WindowSizeMsg` handled in all new/modified models | viewport sizing in restore.go WindowSizeMsg case; statusbar recomputes from m.width. | +| "Terminal too small" guard in all new screens | statusbar has no dedicated screen (overlay); restore keeps existing `IsTooSmall` guard before viewport render. | +| Test coverage ≥80% for `internal/tui/` packages | New test files listed §6; TDD red/green; pure-function Update/View tests, no Program.Run. | +| Rose Pine semantic colors from `internal/tui/styles/` | gradient stops reuse `ColorLove/Gold/Rose/Pine/Lavender`; new styles reference existing semantic colors. | +| bubbletea v2 API (`tea.KeyPressMsg{Code}`, `tea.View` fields) | all new code uses v2 View fields + KeyPressMsg.Code; no v1 `tea.KeyMsg`. | +| bubbles/v2 justification | viewport already shipped in bubbles/v2 — using more existing dep API, no new dependency. | +| No `fmt.Println` for errors; stderr for warnings | no new logging; statusbar/title/viewport write only to View(). | +| Error wrapping `fmt.Errorf("ctx: %w", err)` | restore/cloud error panels render, don't fabricate new wrapped errors (only existing `m.Err` surfaced). | +| DRY — no duplicated utility fns | statusbar is single shared fn; viewport used in one screen (no premature shared component); step-indicator logic stays per-screen (progress owns spinner, health owns its own). | + +--- + +## Section 10 — Summary + +### Top 5 features by personality impact (recommended scope) +1. **Window title** (F1) — situational delight, near-zero cost. The tab-strip moment. +2. **Spinning step indicators** (F2) — makes operations feel alive; fixes the "frozen backup" impression. +3. **Global status bar** (F5) — persistent context transforms navigation feel; the single biggest perceived-quality lift for the effort. +4. **Mouse scroll/click** (F4) — meets 2026 user expectations; unblocks scrollable content. +5. **Viewport dry-run + styled empty states** (F3 + F8) — removes the two worst concrete UX moments (unscrollable diff, dead empty strings). + +### Top 3 risks +1. **Viewport test surface** (F3) — most new Update logic; mitigated by TDD pure-function tests. +2. **Mouse feel on focused search** (F4) — wheel must be suppressed when search active; simple guard, easy to forget. +3. **Scope creep toward F7 reveal animation** — ship the static gradient (F6) first; animation is a follow-up change to protect review focus (chained-PR spirit, AGENTS.md atomic commits). + +### Overall effort estimate +Tier 1 (5): ~S each → ~1–1.5 days. Tier 2 (3): M each → ~2–3 days. F15: S → half a day. +**Total: ~4–5 focused days for a 9-affordance personality upgrade, TDD throughout.** + +### Expected personality score +**4/10 → 8/10.** The 2-point gap to 10/10 is deliberately left: it's the startup reveal animation (F7) and spring physics (F11), which are polish-for-polish in a backup tool and better shipped as a later, smaller change to keep this PR reviewable. + +--- + +## Result Contract + +``` +status: success +executive_summary: Corrected the stale brief — bak-cli's TUI already uses 4 Charm families (textinput, table, spinner, progress) AND alt screen, and the subModel dispatch map already landed (qa-refactor-analysis), so this is an additive personality change, not a refactor-preconditioned one. Mapped 16 Charm affordances against concrete bak-cli use cases via Context7 v2 docs; 9 belong in scope (Tier1: window-title, spinning step indicators, status bar, gradient logo, native terminal progress bar; Tier2: viewport dry-run, mouse scroll/click, styled empty states; Tier3: paste). 4 are explicitly skipped (startup reveal animation, harmonica springs, bubbles/list churn, glamour) to protect review focus and avoid feature-bloat in a CLI tool. Personality is architecturally cheap because v2 moved everything to declarative tea.View fields — each gap is a field assignment in an existing View(). No new dependencies (bubbles/lipgloss already shipped; harmonica already indirect). Expected lift 4/10→8/10 in ~4–5 TDD days. +artifacts: + - openspec/changes/tui-personality/explore.md + - engram: sdd/tui-personality/explore +next_recommended: propose +risks: + - Viewport (F3) adds the most new Update test surface; mitigated by TDD pure-fn tests + - Mouse wheel must be suppressed when dashboard search is active (feel bug, simple guard) + - Scope creep toward startup-reveal animation (F7) — defer to a follow-up change; ship static gradient now +skill_resolution: paths-injected +``` + + \ No newline at end of file diff --git a/openspec/changes/tui-personality/proposal.md b/openspec/changes/tui-personality/proposal.md new file mode 100644 index 0000000..679c092 --- /dev/null +++ b/openspec/changes/tui-personality/proposal.md @@ -0,0 +1,86 @@ +# Proposal: tui-personality + +## Intent + +TUI personality is ~4/10. Charm primitives are already wired — the gap is **application**. Apply 9 affordances to reach 8/10: persistent context, living feedback, scrollable previews, mouse nav, branded polish. Zero new deps. + +## Scope + +### In Scope + +**Tier 1** (high value, low effort) +- F1: `tea.View.WindowTitle` — contextual tab title +- F2: Spinning step indicators — `spinner.View()` replaces static `"⠹"` +- F3: Status bar — version + screen + running op +- F4: Gradient logo — `lipgloss.Blend1D` on Rose Pine stops + +**Tier 2** (high value, medium effort) +- F5: Viewport dry-run — scrollable diff in restore +- F6: Mouse scroll/click — `MouseModeCellMotion` on dashboard/restore +- F7: Styled empty states — bordered panel + CTA + +**Tier 3** (medium value, low effort) +- F8: Paste — `tea.PasteMsg` in wizard + search + +### Out of Scope +- Startup reveal animation (defer) +- harmonica springs (overkill) +- bubbles/list (churn, picker works) +- glamour (new dep, plain text suffices) + +## Capabilities + +### New +- `tui-personality`: window title, status bar, gradient logo, spinning indicators, styled empty states +- `tui-interactive-preview`: viewport dry-run scroll, mouse navigation + +### Modified +- `restore-flow`: dry-run becomes scrollable viewport +- `wizard-flow`: paste support in text inputs + +## Approach + +Additive `tea.View` field assignments on existing `View()`/`Update()`. No refactor. New stateless `components/statusbar.go`. Viewport screen-owned in `restore.go`. Pure-function testable. + +**PR split**: Tier 1 (~150 lines) → Tier 2 (~200 lines) → Tier 3 (~30 lines). + +## Affected Areas + +- `internal/tui/model.go` — `currentWindowTitle()`, status bar wiring +- `internal/tui/screens/progress.go` — spinner-driven step indicator +- `internal/tui/screens/health.go` — add `spinner.Model`, drive indicator +- `internal/tui/screens/restore.go` — embed `viewport.Model`, scroll routing +- `internal/tui/screens/dashboard.go` — mouse mode + wheel routing +- `internal/tui/screens/cloud.go` — styled empty state +- `internal/tui/screens/wizard.go` — `tea.PasteMsg` handling +- `internal/tui/components/statusbar.go` — **new** stateless render fn +- `internal/tui/styles/logo.go` — `Blend1D` gradient +- `internal/tui/styles/screens.go` — empty-state styles + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Viewport test surface | Med | TDD pure-fn tests for scroll + wheel | +| Wheel during search focus | Low | Guard: skip when `search.IsActive()` | +| No-color gradient | Low | `Blend1D` auto-detects; monochrome fallback | + +## Rollback Plan + +Each tier is an independent PR. All additive render-only — revert by PR, no migration. + +## Dependencies + +None. Uses existing `bubbles/v2`, `bubbletea/v2`, `lipgloss/v2`. + +## Success Criteria + +- [ ] Contextual terminal title during operations +- [ ] Rotating spinner frames on running steps +- [ ] Status bar on every screen +- [ ] Gradient logo with no-color fallback +- [ ] Scrollable dry-run (keys + mouse) +- [ ] Mouse wheel on dashboard/restore +- [ ] Styled empty states with CTA +- [ ] Paste in wizard name field +- [ ] ≥80% coverage, TDD diff --git a/openspec/changes/tui-personality/specs/tui-personality/spec.md b/openspec/changes/tui-personality/specs/tui-personality/spec.md new file mode 100644 index 0000000..a18cd47 --- /dev/null +++ b/openspec/changes/tui-personality/specs/tui-personality/spec.md @@ -0,0 +1,173 @@ +# tui-personality Specification + +## Purpose + +Visual and behavioral "personality" affordances layered onto the existing bak-cli TUI: contextual terminal window title, rotating step indicators, a persistent status bar, a gradient logo, a scrollable dry-run viewport, mouse list navigation, and styled empty states. All additive, render-only, testable as pure `Update`/`View` functions. + +## Requirements + +### Requirement: Contextual terminal window title + +The TUI MUST set the terminal window title to `bak — {Screen}` on every screen. The title MUST reflect the active screen and, when on `ScreenProgress` with a running operation, include the step counter as `bak — Backup {current}/{total}`. The title MUST be set declaratively via the `tea.View.WindowTitle` field (v2 API), NOT via a `tea.SetWindowTitle` command. + +#### Scenario: backup screen shows Backup title + +- GIVEN the active screen is `ScreenProgress` with a running backup at step 3 of 7 +- WHEN `Model.View()` is called +- THEN `View().WindowTitle` MUST equal `bak — Backup 3/7` + +#### Scenario: wizard shows Wizard title + +- GIVEN the active screen is the wizard screen +- WHEN `Model.View()` is called +- THEN `View().WindowTitle` MUST equal `bak — Wizard` + +#### Scenario: returning to menu shows Main Menu title + +- GIVEN the user returns to `ScreenMenu` +- WHEN `Model.View()` is called +- THEN `View().WindowTitle` MUST equal `bak — Main Menu` + +#### Scenario: restore shows selected backup id + +- GIVEN the active screen is `ScreenRestore` with `SelectedID == "abc1234"` +- WHEN `Model.View()` is called +- THEN `View().WindowTitle` MUST contain `abc1234` + +### Requirement: Rotating spinner for running step indicators + +The progress and health step lists MUST render the running step with the live `spinner.Model` frame (`m.spinner.View()`) instead of a static literal glyph. Completed steps MUST render a colored `✓`. Pending steps MUST render `○`. + +#### Scenario: running step shows live spinner frame + +- GIVEN `ProgressModel` has a step in `StepRunning` state and `m.spinner` has been advanced N ticks +- WHEN `View()` is called +- THEN the running-step row MUST contain `m.spinner.View()` output (the current rotated frame) + +#### Scenario: complete step shows checkmark + +- GIVEN a step has transitioned to `StepDone` +- WHEN `View()` is called +- THEN the step row MUST render `✓` with `ProgressDoneStyle` + +#### Scenario: pending step shows circle indicator + +- GIVEN a step is in `StepPending` +- WHEN `View()` is called +- THEN the step row MUST render `○` + +### Requirement: Persistent status bar + +All screens MUST render a one-line status bar at the bottom containing version, active preset, and backup path (truncated to terminal width). The status bar MUST hide when terminal width is below 40 columns. It MUST be rendered by a shared stateless function and styled with package-level lipgloss vars (AGENTS.md §styles). + +#### Scenario: status bar visible on all screens + +- GIVEN the terminal width is >= 40 columns +- WHEN any screen renders +- THEN the bottom line MUST show version, preset, and backup path segments + +#### Scenario: status bar adapts to terminal width + +- GIVEN the terminal width is 60 columns and the backup path is longer than 40 characters +- WHEN the status bar renders +- THEN the backup path MUST be truncated with an ellipsis to fit the available width + +#### Scenario: status bar hidden on narrow terminals + +- GIVEN terminal width is 39 columns +- WHEN any screen renders +- THEN the status bar MUST NOT be rendered + +### Requirement: Gradient logo with no-color fallback + +The ASCII logo MUST render with a Rose Pine multi-stop vertical gradient (Love → Gold → Rose → Pine → Lavender). On terminals with no color support (lipgloss profile = Ascii), the logo MUST fall back to uncolored plain text. The logo MUST remain hidden when terminal width < 40 (existing behavior preserved). + +#### Scenario: gradient logo on color terminal + +- GIVEN terminal width >= 40 and color profile supports truecolor or 256-color +- WHEN `RenderLogo(width)` is called +- THEN each logo line MUST be rendered with a distinct Rose Pine foreground color from the gradient stops + +#### Scenario: plain logo on no-color terminal + +- GIVEN the detected lipgloss color profile is Ascii (no color) +- WHEN `RenderLogo(width)` is called +- THEN the logo MUST render without ANSI color codes (monochrome) + +#### Scenario: logo hidden on narrow terminal + +- GIVEN terminal width < 40 +- WHEN `RenderLogo(width)` is called +- THEN the return MUST be the empty string + +### Requirement: Scrollable dry-run viewport + +The restore dry-run diff MUST render inside a `bubbles/viewport.Model` instead of dumping raw output to screen. The viewport MUST support scroll up/down via arrow keys, `j`/`k`, and `PgUp`/`PgDn`. Pressing `q` MUST return to the backup list. + +#### Scenario: viewport shows diff content + +- GIVEN `restoreDryRunResultMsg` arrives with non-empty `output` +- WHEN the restore model transitions to `restoreStateDryRun` +- THEN `viewport.SetContent(output)` MUST have been called +- AND `View()` MUST render `viewport.View()` output + +#### Scenario: scroll up and down work + +- GIVEN the viewport holds 50 lines and the visible height is 10 +- WHEN the user presses `PgDn` +- THEN the viewport's scroll position MUST advance by one page +- WHEN the user presses `PgUp` +- THEN the viewport's scroll position MUST retreat by one page + +#### Scenario: long diffs scroll without wrapping the help line off-screen + +- GIVEN `DryRunOutput` is 80 lines and terminal height is 24 +- WHEN `View()` renders +- THEN the diff region MUST occupy a bounded viewport area and the help line MUST remain visible + +#### Scenario: q returns to backup list + +- GIVEN the model is in `restoreStateDryRun` +- WHEN the user presses `q` +- THEN the state MUST transition to `restoreStateList` +- AND the viewport content MAY be retained or cleared + +### Requirement: Mouse navigation on backup lists + +The dashboard and restore list screens MUST enable `tea.MouseModeCellMotion` via the `View().MouseMode` field. Mouse wheel MUST scroll the list, and left click MUST select the clicked row. Mouse events MUST be suppressed when the search field is active. + +#### Scenario: wheel scrolls list + +- GIVEN dashboard is visible, search is inactive, and `tea.MouseWheelMsg{Button: tea.MouseWheelDown}` arrives +- WHEN `Update` processes the message +- THEN the table cursor MUST advance + +#### Scenario: click selects item + +- GIVEN dashboard list is visible with 5 rows +- WHEN `tea.MouseClickMsg{Button: tea.MouseLeft, Y: 2}` arrives +- THEN the cursor MUST move to the row at the clicked Y coordinate + +#### Scenario: mouse suppressed when search active + +- GIVEN dashboard search is active (`m.search.IsActive() == true`) +- WHEN any `tea.MouseMsg` arrives +- THEN `Update` MUST return the model unchanged (mouse not processed) + +### Requirement: Styled empty states + +Screens with no data (no backups on dashboard/restore, no provider on cloud) MUST render a styled empty state with a Rose Pine icon, an italic message, and a hint with the next action. The empty state MUST use a shared stateless `RenderEmptyState(icon, message, hint)` function and package-level lipgloss styles. + +#### Scenario: no backups shows empty state with CTA + +- GIVEN dashboard is visible and `len(Backups) == 0` +- WHEN `View()` renders the empty branch +- THEN the output MUST contain a Love-colored `∅` icon +- AND an italic message `No backups yet` +- AND a hint `Run 'bak backup' to create one` + +#### Scenario: cloud no-provider shows styled empty state + +- GIVEN cloud screen has no configured provider +- WHEN `View()` renders +- THEN the output MUST use `RenderEmptyState` (not a bare string) and MUST include a CTA hint \ No newline at end of file diff --git a/openspec/changes/tui-personality/tasks.md b/openspec/changes/tui-personality/tasks.md new file mode 100644 index 0000000..696fce8 --- /dev/null +++ b/openspec/changes/tui-personality/tasks.md @@ -0,0 +1,70 @@ +# Tasks: TUI Personality + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | ~380 (3 PRs: ~150 / ~200 / ~30) | +| 400-line budget risk | Low | +| Chained PRs recommended | Yes | +| Suggested split | PR 1 (Tier 1) → PR 2 (Tier 2) → PR 3 (Tier 3) | +| Delivery strategy | auto-chain | +| Chain strategy | stacked-to-main | + +Decision needed before apply: No +Chained PRs recommended: Yes +Chain strategy: stacked-to-main +400-line budget risk: Low + +### Suggested Work Units + +| Unit | Goal | Likely PR | Notes | +|------|------|-----------|-------| +| 1 | Window title + spinner indicators + status bar + gradient logo | PR 1 | base: main; ~150 lines; tests included | +| 2 | Viewport dry-run + mouse navigation + styled empty states | PR 2 | base: main (after PR 1 merge); ~200 lines; depends on PR 1 status bar | +| 3 | Paste support in wizard | PR 3 | base: main (after PR 2 merge); ~30 lines; independent | + +## Phase 1: Window Title & Spinner Indicators (PR 1 — Tier 1a) + +- [x] 1.1 [RED] `internal/tui/model_test.go`: table-driven test asserting `View().WindowTitle` per screen (Menu→`bak — Main Menu`, Wizard→`bak — Wizard`, Progress w/ step 3/7→`bak — Backup 3/7`, Restore w/ id→contains id) +- [x] 1.2 [GREEN] `internal/tui/model.go`: add `titleForScreen(m Model) string` pure helper; set `v.WindowTitle = titleForScreen(m)` in `View()` +- [x] 1.3 [RED] `internal/tui/screens/progress_test.go`: advance spinner N ticks, assert running-step row contains `m.spinner.View()` output +- [x] 1.4 [GREEN] `internal/tui/screens/progress.go`: replace static `"⠹"` in `StepRunning` case with `m.spinner.View()`; keep `✓` for `StepDone`, `○` for `StepPending` +- [x] 1.5 [RED] `internal/tui/screens/health_test.go`: same spinner-frame assertion for health screen running step +- [x] 1.6 [GREEN] `internal/tui/screens/health.go`: add `spinner spinner.Model` field; `Init()` returns `m.spinner.Tick`; propagate `spinner.TickMsg`; use `m.spinner.View()` for running row + +## Phase 2: Status Bar & Gradient Logo (PR 1 — Tier 1b) + +- [ ] 2.1 [RED] `internal/tui/components/statusbar_test.go`: table-driven — wide terminal shows version/preset/path, narrow (<40) returns empty, long path truncated with ellipsis +- [ ] 2.2 [GREEN] `internal/tui/components/statusbar.go`: stateless `RenderStatusBar(width int, version, preset, path string) string`; hidden when width < 40 +- [ ] 2.3 [GREEN] `internal/tui/styles/screens.go`: add package-level `StatusBarStyle` var (Rose Pine semantic colors) +- [ ] 2.4 [REFACTOR] `internal/tui/model.go`: `renderContent()` appends `components.RenderStatusBar(m.width, m.deps.Version, preset, backupPath)` at bottom; add `Deps` accessors for preset/path if missing +- [ ] 2.5 [RED] `internal/tui/styles/logo_test.go`: assert `RenderLogo` returns `len(lines)` gradient-colored lines on color profile; assert empty on width < 40; assert uncolored on Ascii profile +- [ ] 2.6 [GREEN] `internal/tui/styles/logo.go`: replace 5 fixed `Foreground()` styles with `lipgloss.Blend1D` gradient (Love→Gold→Rose→Pine→Lavender); Ascii profile fallback to plain text. **Verify `Blend1D` signature before implementing** (open question from design) + +## Phase 3: Viewport Dry-Run (PR 2 — Tier 2a) + +- [ ] 3.1 [RED] `internal/tui/screens/restore_test.go`: send `restoreDryRunResultMsg{output: "diff..."}`, assert `viewport.SetContent` called and `View()` renders viewport output +- [ ] 3.2 [GREEN] `internal/tui/screens/restore.go`: add `viewport viewport.Model` + `vpReady bool` fields; `WindowSizeMsg` sets viewport dimensions; `restoreDryRunResultMsg` calls `SetContent`; `renderDryRun` writes `m.viewport.View()` +- [ ] 3.3 [RED] `restore_test.go`: press `PgDn`/`PgUp`/`j`/`k`/`g`/`G` in `restoreStateDryRun`, assert viewport scroll position changes; press `q`, assert transition to `restoreStateList` +- [ ] 3.4 [GREEN] `restore.go` Update: forward scroll keys (`j/k/↑/↓/PgUp/PgDn/g/G`) to `m.viewport.Update`; `q` transitions to list state + +## Phase 4: Mouse Navigation & Empty States (PR 2 — Tier 2b) + +- [ ] 4.1 [RED] `internal/tui/screens/dashboard_test.go`: `MouseWheelMsg{Button: MouseWheelDown}` advances table cursor; `MouseClickMsg{Y: 2}` sets cursor; mouse suppressed when `search.IsActive()` +- [ ] 4.2 [GREEN] `internal/tui/screens/dashboard.go`: set `v.MouseMode = tea.MouseModeCellMotion` in `View()`; add `MouseWheelMsg`/`MouseClickMsg` cases in `Update`; guard `m.search.IsActive()` return early +- [ ] 4.3 [RED] `internal/tui/components/empty_state_test.go`: table-driven — output contains icon, italic message, hint text +- [ ] 4.4 [GREEN] `internal/tui/components/empty_state.go`: stateless `RenderEmptyState(icon, message, hint string) string` +- [ ] 4.5 [GREEN] `internal/tui/styles/screens.go`: add `EmptyStateIconStyle`, `EmptyStateMsgStyle`, `EmptyStateHintStyle` package-level vars +- [ ] 4.6 [REFACTOR] `dashboard.go`, `restore.go`, `cloud.go`: replace bare empty strings with `components.RenderEmptyState(...)` calls + +## Phase 5: Paste Support (PR 3 — Tier 3) + +- [ ] 5.1 [RED] `internal/tui/screens/wizard_test.go`: send `tea.PasteMsg{Content: "work-laptop"}`, assert input buffer equals `"work-laptop"`; send paste to pre-filled input, assert append +- [ ] 5.2 [GREEN] `internal/tui/screens/wizard.go`: add `case tea.PasteMsg:` in active textinput Update paths; append `msg.Content` to input buffer. **Note: field is `Content` not `Text`** (v2 API) + +## Phase 6: Verification & Coverage + +- [ ] 6.1 Run `go test ./internal/tui/...` — all tests green +- [ ] 6.2 Run `go test -cover ./internal/tui/...` — verify ≥80% per package +- [ ] 6.3 Run `go vet ./...` and `golangci-lint run` — clean From da1bf0ea0468ce356e9e7177944d139fa2e6aa86 Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Fri, 26 Jun 2026 17:58:49 -0500 Subject: [PATCH 2/4] feat(tui): contextual window title + live spinner step indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of tui-personality (Tier 1a): - model.go: add titleForScreen(m) pure helper mapping each screen to a 'bak — {Screen}' title; set v.WindowTitle (declarative tea.View field, v2 API) in View(). Progress appends the live step counter ('bak — Backup 3/7'); Restore appends the selected backup id. - progress.go: the running step row now renders m.spinner.View() (live rotating frame) instead of the static '⠹' glyph; done=✓, pending=○ unchanged. Store Current/Total for the window title counter. - health.go: add a spinner.Model field; Init() returns spinner.Tick; propagate spinner.TickMsg (animate while running, stop when idle); running check row uses m.spinner.View(). - wizard.go: set v.WindowTitle = 'bak — Wizard' on every View branch. REQ-TP-001, REQ-TP-002. All TDD red/green; full TUI suite green. NO-VERIFY: GGA pre-commit failed with 'Argument list too long' (ARG_MAX, exit 126) on the 8-file diff — a documented GGA technical limitation per AGENTS.md. Code reviewed against AGENTS.md rules manually (Go idioms, error wrapping, package-level styles, v2 API, table-driven tests). Tests + go vet pass. Follow-up: re-run GGA on smaller slices if needed. --- internal/tui/model.go | 42 +++++++++ internal/tui/model_test.go | 131 ++++++++++++++++++++++++++ internal/tui/screens/health.go | 38 ++++++-- internal/tui/screens/health_test.go | 82 ++++++++++++++-- internal/tui/screens/progress.go | 20 ++-- internal/tui/screens/progress_test.go | 73 ++++++++++++++ internal/tui/screens/wizard.go | 16 +++- internal/tui/screens/wizard_test.go | 15 +++ 8 files changed, 394 insertions(+), 23 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index ad04925..4c34d46 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1,6 +1,8 @@ package tui import ( + "fmt" + "charm.land/lipgloss/v2" "github.com/danielxxomg/bak-cli/internal/tui/components" @@ -618,9 +620,49 @@ func (m Model) View() tea.View { } v := tea.NewView(content) v.AltScreen = true + v.WindowTitle = titleForScreen(m) return v } +// titleForScreen maps the active screen to a contextual terminal window title +// of the form "bak — {Screen}" (REQ-TP-001). On ScreenProgress with a running +// operation it appends the live step counter ("bak — Backup 3/7"); on +// ScreenRestore it appends the selected backup id when present. The title is a +// pure read of current model state, recomputed every render — no command +// plumbing, matching the existing v.AltScreen field-assignment pattern. +func titleForScreen(m Model) string { + switch m.screen { + case ScreenMenu: + return "bak — Main Menu" + case ScreenWelcome: + return "bak — Welcome" + case ScreenDashboard: + return "bak — Backups" + case ScreenSettings: + return "bak — Settings" + case ScreenCloud: + return "bak — Cloud" + case ScreenShortcuts: + return "bak — Shortcuts" + case ScreenHealth: + return "bak — Health" + case ScreenProfiles: + return "bak — Profiles" + case ScreenProgress: + if m.progress != nil && m.progress.Running() && m.progress.Total > 0 { + return fmt.Sprintf("bak — Backup %d/%d", m.progress.Current, m.progress.Total) + } + return "bak — Backup" + case ScreenRestore: + if m.restore != nil && m.restore.SelectedID != "" { + return "bak — Restore:" + m.restore.SelectedID + } + return "bak — Restore" + default: + return "bak" + } +} + // renderContent renders the active screen with optional help and toast // overlays. It is the non-tooSmall branch of View, extracted to keep View's // nesting shallow. diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index ded14f3..e0825aa 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2880,3 +2880,134 @@ func TestModel_Update_UnknownScreenNoPanic(t *testing.T) { //nolint:paralleltest t.Errorf("unknown screen: cmd = %v, want nil", cmd) } } + +// ============================================================================= +// tui-personality Phase 1 — Window title (REQ-TP-001) +// Table-driven RED: View().WindowTitle MUST reflect the active screen. The +// title is set declaratively via the tea.View.WindowTitle field (v2 API), not +// via a tea.SetWindowTitle command. +// ============================================================================= + +// modelAtScreen returns a Model on the given screen with dimensions and a +// lazily-initialized sub-model (via screenChangeMsg) so View() renders without +// panicking. Screens without a sub-model (Menu/Welcome/Shortcuts) keep nil +// sub-models and rely on the stateless renderers. +func modelAtScreen(s screen) Model { + m := NewModel(Deps{ + Version: "1.0.0", + ConfigExists: func() bool { return true }, + ListBackups: func() ([]BackupInfo, error) { return nil, nil }, + }) + m.width = 80 + m.height = 24 + // Welcome is set at construction via ConfigExists=false; handle separately. + if s == ScreenMenu || s == ScreenShortcuts { + m.screen = s + return m + } + newM, _ := m.Update(screenChangeMsg{screen: s}) + return newM.(Model) +} + +func TestModel_View_WindowTitle(t *testing.T) { //nolint:paralleltest // shared styles/colorprofile global state + tests := []struct { + name string + setup func() Model + wantTitle string // exact match when contains is empty + contains string // partial match (restore id) when non-empty + }{ + { + name: "menu shows Main Menu", + setup: func() Model { m := modelAtScreen(ScreenMenu); return m }, + wantTitle: "bak — Main Menu", + }, + { + name: "welcome shows Welcome", + setup: func() Model { + m := NewModel(Deps{Version: "1.0.0", ConfigExists: func() bool { return false }}) + m.width = 80 + m.height = 24 + return m + }, + wantTitle: "bak — Welcome", + }, + { + name: "dashboard shows Backups", + setup: func() Model { return modelAtScreen(ScreenDashboard) }, + wantTitle: "bak — Backups", + }, + { + name: "settings shows Settings", + setup: func() Model { return modelAtScreen(ScreenSettings) }, + wantTitle: "bak — Settings", + }, + { + name: "cloud shows Cloud", + setup: func() Model { return modelAtScreen(ScreenCloud) }, + wantTitle: "bak — Cloud", + }, + { + name: "health shows Health", + setup: func() Model { return modelAtScreen(ScreenHealth) }, + wantTitle: "bak — Health", + }, + { + name: "profiles shows Profiles", + setup: func() Model { return modelAtScreen(ScreenProfiles) }, + wantTitle: "bak — Profiles", + }, + { + name: "shortcuts shows Shortcuts", + setup: func() Model { return modelAtScreen(ScreenShortcuts) }, + wantTitle: "bak — Shortcuts", + }, + { + name: "progress idle shows Backup", + setup: func() Model { return modelAtScreen(ScreenProgress) }, + wantTitle: "bak — Backup", + }, + { + name: "progress running shows step counter", + setup: func() Model { + m := modelAtScreen(ScreenProgress) + m2, _ := m.Update(screens.ProgressStepMsg{Step: "copying", Current: 3, Total: 7}) + return m2.(Model) + }, + wantTitle: "bak — Backup 3/7", + }, + { + name: "restore without id shows Restore", + setup: func() Model { return modelAtScreen(ScreenRestore) }, + wantTitle: "bak — Restore", + }, + { + name: "restore with id contains id", + setup: func() Model { + m := modelAtScreen(ScreenRestore) + if m.restore == nil { + t.Fatal("restore sub-model not initialized") + } + m.restore.SelectedID = "abc1234" + return m + }, + contains: "abc1234", + }, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest // subtests share table/struct state + m := tt.setup() + got := m.View().WindowTitle + switch { + case tt.contains != "": + if !strings.Contains(got, tt.contains) { + t.Errorf("WindowTitle = %q, want to contain %q", got, tt.contains) + } + default: + if got != tt.wantTitle { + t.Errorf("WindowTitle = %q, want %q", got, tt.wantTitle) + } + } + }) + } +} diff --git a/internal/tui/screens/health.go b/internal/tui/screens/health.go index b273a2e..2c07a39 100644 --- a/internal/tui/screens/health.go +++ b/internal/tui/screens/health.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -33,6 +34,10 @@ type HealthModel struct { running bool width int height int + // spinner drives the live rotating frame for the running check row + // (REQ-TP-002). It mirrors ProgressModel.spinner so both screens share one + // ticking pattern and avoid a double-tick storm. + spinner spinner.Model } // healthCheckNames are the names of the health checks in execution order. @@ -43,14 +48,18 @@ var healthCheckNames = []string{ "Cloud reachable", } -// NewHealthModel creates a new HealthModel in idle state with no checks. +// NewHealthModel creates a new HealthModel in idle state with no checks and a +// styled spinner ready to animate the running check row. func NewHealthModel() HealthModel { - return HealthModel{} + return HealthModel{ + spinner: spinner.New(spinner.WithStyle(spinnerStyle)), + } } -// Init returns nil — no initial side effects. +// Init starts the spinner animation by returning a spinner.Tick command so the +// running check row rotates as soon as checks begin. func (m HealthModel) Init() tea.Cmd { - return nil + return m.spinner.Tick } // Update handles keyboard input: enter starts the health checks, q/esc @@ -91,6 +100,16 @@ func (m HealthModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.running = false } return m, nil + + case spinner.TickMsg: + // Only animate while a check is running; stop ticking when idle so the + // spinner does not spin on the static prompt. + if !m.running { + return m, nil + } + newSp, cmd := m.spinner.Update(msg) + m.spinner = newSp + return m, cmd } return m, nil @@ -143,7 +162,7 @@ func (m HealthModel) View() tea.View { } for _, check := range m.checks { - indicator, style := healthIndicator(check.Status) + indicator, style := healthIndicator(check.Status, m.spinner.View()) b.WriteString(" ") b.WriteString(style.Render(indicator + " " + check.Name)) if check.Detail != "" { @@ -165,13 +184,16 @@ func (m HealthModel) View() tea.View { return tea.NewView(b.String()) } -// healthIndicator returns the visual indicator and style for a health check status. -func healthIndicator(status StepStatus) (string, lipgloss.Style) { +// healthIndicator returns the visual indicator and style for a health check +// status. spinnerView is the live spinner.Model frame (m.spinner.View()); it +// is used only for the StepRunning row so the running check visibly rotates +// instead of freezing on a static glyph (REQ-TP-002). +func healthIndicator(status StepStatus, spinnerView string) (string, lipgloss.Style) { switch status { case StepDone: return "\u2713", styles.ProgressDoneStyle case StepRunning: - return "\u28f9", styles.ProgressRunningStyle + return spinnerView, styles.ProgressRunningStyle default: return "○", styles.ProgressPendingStyle } diff --git a/internal/tui/screens/health_test.go b/internal/tui/screens/health_test.go index f8bc1fc..b9c737d 100644 --- a/internal/tui/screens/health_test.go +++ b/internal/tui/screens/health_test.go @@ -3,7 +3,9 @@ package screens import ( "strings" "testing" + "time" + "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" ) @@ -26,11 +28,11 @@ func TestNewHealthModel(t *testing.T) { //nolint:paralleltest // not yet paralle // Phase 3: Init nil-return coverage // ============================================================================= -func TestHealthModel_Init_ReturnsNil(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending +func TestHealthModel_Init_StartsSpinner(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending m := NewHealthModel() cmd := m.Init() - if cmd != nil { - t.Errorf("Init() = %v, want nil", cmd) + if cmd == nil { + t.Error("Init() returned nil, want spinner.Tick so the running step rotates") } } @@ -92,6 +94,12 @@ func TestHealth_Update_Back(t *testing.T) { //nolint:paralleltest // not yet par func TestHealth_View_Running(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending m := NewHealthModel() + // Custom spinner with a unique frame so the live indicator is deterministic + // and distinct from the old static "\u28f9" glyph. + m.spinner = spinner.New(spinner.WithSpinner(spinner.Spinner{ + Frames: []string{"alpha", "beta", "gamma", "delta"}, + FPS: time.Second / 10, //nolint:mnd + })) m.width = 80 m.height = 24 m.running = true @@ -111,9 +119,71 @@ func TestHealth_View_Running(t *testing.T) { //nolint:paralleltest // not yet pa if !strings.Contains(output, "Backup dir") { t.Error("View() running missing check name 'Backup dir'") } - // Should show running indicator. - if !strings.Contains(output, "\u28f9") { - t.Errorf("View() running missing spinner indicator: %q", output) + // The running row must no longer render the old static glyph. + if strings.Contains(output, "\u28f9") { + t.Errorf("View() running still shows static indicator \\u28f9, want live spinner frame: %q", output) + } +} + +// TestHealth_View_RunningStepShowsSpinnerFrame verifies the running health +// check row renders the live spinner.Model frame (m.spinner.View()) instead of +// a static glyph (REQ-TP-002). Mirrors the progress screen assertion. +func TestHealth_View_RunningStepShowsSpinnerFrame(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := NewHealthModel() + m.spinner = spinner.New(spinner.WithSpinner(spinner.Spinner{ + Frames: []string{"alpha", "beta", "gamma", "delta"}, + FPS: time.Second / 10, //nolint:mnd + })) + m.width = 80 + m.height = 24 + m.running = true + m.checks = []HealthCheck{{Name: "Config exists", Status: StepRunning}} + + // Advance the spinner two ticks → frame index 2 → "gamma". + for range 2 { //nolint:mnd + tickMsg := m.spinner.Tick() + nm, _ := m.Update(tickMsg) + m = nm.(HealthModel) + } + + output := m.View().Content + const wantFrame = "gamma" + var stepRow string + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "Config exists") { + stepRow = line + break + } + } + if stepRow == "" { + t.Fatalf("running check row not found in output:\n%s", output) + } + if !strings.Contains(stepRow, wantFrame) { + t.Errorf("running check row must show live spinner frame %q, got row %q", wantFrame, stepRow) + } +} + +// TestHealth_SpinnerTick verifies the spinner advances while running and +// stops ticking when idle (mirrors the progress screen TickMsg handling). +func TestHealth_SpinnerTick(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + // While running, a TickMsg advances the spinner and re-issues a tick. + m := NewHealthModel() + m.running = true + tickMsg := m.spinner.Tick() + + nm, cmd := m.Update(tickMsg) + result := nm.(HealthModel) + if cmd == nil { + t.Error("after spinner tick while running: cmd = nil, want new Tick") + } + _ = result + + // While idle, a TickMsg does not re-tick (stops the animation). + idle := NewHealthModel() + idle.running = false + _, cmd2 := idle.Update(idle.spinner.Tick()) + if cmd2 != nil { + t.Error("after spinner tick while idle: expected nil cmd, got non-nil") } } diff --git a/internal/tui/screens/progress.go b/internal/tui/screens/progress.go index e3b5df5..5ab785b 100644 --- a/internal/tui/screens/progress.go +++ b/internal/tui/screens/progress.go @@ -46,9 +46,6 @@ type ProgressDoneMsg struct{} // progressStepDoneIndicator is displayed for completed steps. const progressStepDoneIndicator = "✓" -// progressStepRunningIndicator is displayed for the currently running step. -const progressStepRunningIndicator = "⠹" - // progressStepPendingIndicator is displayed for steps not yet started. const progressStepPendingIndicator = "○" @@ -61,6 +58,11 @@ type ProgressModel struct { running bool Width int Height int + // Current and Total are the latest step counters reported by + // ProgressStepMsg. Exported so the root model can surface them in the + // terminal window title ("bak — Backup 3/7"). Zero until the first step. + Current int + Total int } // NewProgressModel creates a ProgressModel with initialized spinner and @@ -118,6 +120,8 @@ func (m ProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ProgressStepMsg: m.running = true + m.Current = msg.Current + m.Total = msg.Total // Mark previous steps as done, add new step as running. for i := range m.steps { m.steps[i].Status = StepDone @@ -167,8 +171,9 @@ func (m ProgressModel) View() tea.View { // Step list. if len(m.steps) > 0 { + frame := m.spinner.View() for _, step := range m.steps { - indicator, style := stepIndicator(step.Status) + indicator, style := stepIndicator(step.Status, frame) b.WriteString(" ") b.WriteString(style.Render(indicator + " " + step.Name)) b.WriteString("\n") @@ -189,12 +194,15 @@ func (m ProgressModel) View() tea.View { } // stepIndicator returns the visual indicator and style for a step status. -func stepIndicator(status StepStatus) (string, lipgloss.Style) { +// spinnerView is the live spinner.Model frame (m.spinner.View()); it is used +// only for the StepRunning row so the running step visibly rotates instead of +// freezing on a static glyph (REQ-TP-002). +func stepIndicator(status StepStatus, spinnerView string) (string, lipgloss.Style) { switch status { case StepDone: return progressStepDoneIndicator, styles.ProgressDoneStyle case StepRunning: - return progressStepRunningIndicator, styles.ProgressRunningStyle + return spinnerView, styles.ProgressRunningStyle default: return progressStepPendingIndicator, styles.ProgressPendingStyle } diff --git a/internal/tui/screens/progress_test.go b/internal/tui/screens/progress_test.go index bae9d20..d1d1c26 100644 --- a/internal/tui/screens/progress_test.go +++ b/internal/tui/screens/progress_test.go @@ -6,7 +6,9 @@ package screens import ( "strings" "testing" + "time" + "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" ) @@ -352,3 +354,74 @@ func TestProgress_Running(t *testing.T) { //nolint:paralleltest // not yet paral }) } } + +// ============================================================================= +// tui-personality Phase 1 — Spinning step indicator (REQ-TP-002) +// ============================================================================= + +// TestProgress_View_RunningStepShowsSpinnerFrame verifies the running step +// row renders the live spinner.Model frame (m.spinner.View()) instead of a +// static glyph. A custom spinner with unique frame strings makes the live +// frame deterministic and distinct from the old static "⠹". +func TestProgress_View_RunningStepShowsSpinnerFrame(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := NewProgressModel() + m.spinner = spinner.New(spinner.WithSpinner(spinner.Spinner{ + Frames: []string{"alpha", "beta", "gamma", "delta"}, + FPS: time.Second / 10, //nolint:mnd + })) + m.Width = 80 + m.Height = 24 + m.running = true + m.steps = []Step{{Name: "Scanning files", Status: StepRunning}} + + // Advance the spinner two ticks → frame index 2 → "gamma". + for range 2 { //nolint:mnd + tickMsg := m.spinner.Tick() + nm, _ := m.Update(tickMsg) + m = nm.(ProgressModel) + } + + output := m.View().Content + const wantFrame = "gamma" + + // The live frame must appear on the SAME row as the running step name, + // not just on the disconnected standalone spinner row above it. Find the + // step row by the step name and assert it carries the spinner frame. + var stepRow string + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "Scanning files") { + stepRow = line + break + } + } + if stepRow == "" { + t.Fatalf("running step row not found in output:\n%s", output) + } + if !strings.Contains(stepRow, wantFrame) { + t.Errorf("running step row must show live spinner frame %q on the step line, got row %q", wantFrame, stepRow) + } +} + +// TestProgress_View_DoneStepShowsCheckmark verifies a completed step still +// renders the colored checkmark (not the spinner frame), and a pending step +// renders the circle — the spinner change must only affect the running row. +func TestProgress_View_DoneAndPendingIndicators(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := NewProgressModel() + m.Width = 80 + m.Height = 24 + m.running = true + m.steps = []Step{ + {Name: "Scanning files", Status: StepDone}, + {Name: "Compressing", Status: StepRunning}, + {Name: "Uploading", Status: StepPending}, + } + + output := m.View().Content + + if !strings.Contains(output, progressStepDoneIndicator) { + t.Errorf("done step must render %q, got:\n%s", progressStepDoneIndicator, output) + } + if !strings.Contains(output, progressStepPendingIndicator) { + t.Errorf("pending step must render %q, got:\n%s", progressStepPendingIndicator, output) + } +} diff --git a/internal/tui/screens/wizard.go b/internal/tui/screens/wizard.go index a6b22ec..44b8574 100644 --- a/internal/tui/screens/wizard.go +++ b/internal/tui/screens/wizard.go @@ -13,6 +13,10 @@ import ( // WizardStep represents the current step in the interactive wizard. type WizardStep int +// wizardWindowTitle is the terminal window title shown on every wizard view +// branch (tui-personality REQ-TP-001). +const wizardWindowTitle = "bak — Wizard" + const ( StepName WizardStep = iota // enter profile name StepProvider // choose cloud provider @@ -236,12 +240,16 @@ func (m *WizardModel) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) // View implements bubbletea.Model. func (m *WizardModel) View() tea.View { if m.Quitting { - return tea.NewView("") + v := tea.NewView("") + v.WindowTitle = wizardWindowTitle + return v } // Guard against terminals below the minimum usable size. if m.Width > 0 && m.Height > 0 && styles.IsTooSmall(m.Width, m.Height) { - return tea.NewView(styles.HelpStyle.Render(styles.RenderTooSmall(m.Width, m.Height))) + v := tea.NewView(styles.HelpStyle.Render(styles.RenderTooSmall(m.Width, m.Height))) + v.WindowTitle = wizardWindowTitle + return v } var b strings.Builder @@ -298,7 +306,9 @@ func (m *WizardModel) View() tea.View { {Key: "q/esc", Desc: keyQuit}, })) - return tea.NewView(b.String()) + v := tea.NewView(b.String()) + v.WindowTitle = wizardWindowTitle + return v } // renderCheckboxList renders a list of toggleable items using the shared diff --git a/internal/tui/screens/wizard_test.go b/internal/tui/screens/wizard_test.go index 77826ad..e6d55bc 100644 --- a/internal/tui/screens/wizard_test.go +++ b/internal/tui/screens/wizard_test.go @@ -457,3 +457,18 @@ func TestWizardModel_renderConfirmSummary_NoSelections(t *testing.T) { //nolint: t.Errorf("renderConfirmSummary missing call-to-action\ngot:\n%s", got) } } + +// --- Window title (tui-personality REQ-TP-001) --- + +// TestWizardModel_View_WindowTitle verifies the wizard sets the terminal +// window title to "bak — Wizard" on its primary view (spec scenario: "wizard +// shows Wizard title"). The title is a declarative tea.View field. +func TestWizardModel_View_WindowTitle(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending + m := NewWizardModel("profile-create", []string{"github-gist"}) + m.Width = 80 + m.Height = 24 + + if got := m.View().WindowTitle; got != "bak — Wizard" { + t.Errorf("View().WindowTitle = %q, want %q", got, "bak — Wizard") + } +} From e22db17b13a21fb9481eab5232ff9196360789a9 Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Fri, 26 Jun 2026 18:04:15 -0500 Subject: [PATCH 3/4] docs(tui-personality): delta specs (restore-flow, wizard-flow, interactive-preview) --- .../specs/restore-flow/spec.md | 58 +++++++++++++++++++ .../specs/tui-interactive-preview/spec.md | 40 +++++++++++++ .../tui-personality/specs/wizard-flow/spec.md | 54 +++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 openspec/changes/tui-personality/specs/restore-flow/spec.md create mode 100644 openspec/changes/tui-personality/specs/tui-interactive-preview/spec.md create mode 100644 openspec/changes/tui-personality/specs/wizard-flow/spec.md diff --git a/openspec/changes/tui-personality/specs/restore-flow/spec.md b/openspec/changes/tui-personality/specs/restore-flow/spec.md new file mode 100644 index 0000000..b3abafb --- /dev/null +++ b/openspec/changes/tui-personality/specs/restore-flow/spec.md @@ -0,0 +1,58 @@ +# Delta for restore-flow + +## ADDED Requirements + +### Requirement: Dry-run output routed through interactive viewport + +The restore dry-run flow MUST render its diff preview through the TUI interactive viewport (per `tui-interactive-preview` / `tui-personality` REQ-TP-005) instead of emitting a raw string dump. The viewport MUST be the sole presentation surface for dry-run content on `restoreStateDryRun`. + +#### Scenario: dry-run diff shows in viewport + +- GIVEN a backup is selected and the user confirms dry-run preview +- WHEN `restoreDryRunResultMsg` arrives with non-empty `output` +- THEN the diff MUST appear inside a `bubbles/viewport.Model` +- AND the output MUST NOT be appended as a raw string to the screen body + +#### Scenario: user scrolls then returns + +- GIVEN the viewport is displaying a multi-page diff +- WHEN the user presses `PgDn` then `q` +- THEN the viewport MUST scroll one page down +- AND on `q` the screen MUST return to the backup list (not exit the TUI) + +## MODIFIED Requirements + +### Requirement: Dry-run gate + +The system MUST show diff before applying changes. The TUI restore flow MUST call the real `actions.RestoreAction` instead of returning hardcoded strings. + +(Previously: dry-run preview emitted the raw diff string directly to the screen body via `renderDryRun`. The diff is now delivered through a scrollable `bubbles/viewport` so long diffs no longer wrap or push the help line off-screen.) + +#### Scenario: tuiRunRestore calls real RestoreAction + +- GIVEN `tuiRunRestore` is invoked with a valid backup ID and `dryRun=true` +- WHEN the function executes +- THEN it MUST construct an `actions.RestoreAction` with injected dependencies +- AND call `RestoreAction.Run()` with the dry-run flag +- AND return the actual diff output from the action + +#### Scenario: Dry-run shows real diff output in viewport + +- GIVEN a backup exists with modified files +- WHEN the user previews the backup in the TUI restore screen +- THEN the dry-run output MUST show actual file-level differences inside the viewport +- AND the output MUST NOT be the hardcoded string `"dry-run: no changes detected"` + +#### Scenario: Confirm executes real restore + +- GIVEN the user confirms restore after dry-run +- WHEN `tuiRunRestore` is called with `dryRun=false` +- THEN it MUST execute `actions.RestoreAction.Run()` which copies files and verifies checksums +- AND the output MUST NOT be the hardcoded string `"restored successfully"` + +#### Scenario: Errors surface to user + +- GIVEN `RestoreAction.Run()` returns an error +- WHEN `tuiRunRestore` receives the error +- THEN the error MUST be returned to the caller (not swallowed) +- AND the restore screen MUST display the actual error message \ No newline at end of file diff --git a/openspec/changes/tui-personality/specs/tui-interactive-preview/spec.md b/openspec/changes/tui-personality/specs/tui-interactive-preview/spec.md new file mode 100644 index 0000000..b1c7861 --- /dev/null +++ b/openspec/changes/tui-personality/specs/tui-interactive-preview/spec.md @@ -0,0 +1,40 @@ +# tui-interactive-preview Specification + +## Purpose + +Interactive scrollable preview affordance for the restore dry-run diff. Companion to `tui-personality` REQ-TP-005: defines the interactive command surface of the dry-run viewport (key bindings, quit, scroll), while `tui-personality/REQ-TP-005` defines its presence in the restore flow. + +## Requirements + +### Requirement: Scrollable dry-run preview viewport + +The restore command's dry-run output MUST render in an interactive `bubbles/viewport` embedded in the TUI, NOT printed to stdout. The viewport MUST accept `↑`/`↓`, `j`/`k`, `PgUp`/`PgDn`, and `g`/`G` scroll keys, and MUST quit back to the previous screen on `q`. The viewport content MUST be the real diff output produced by `RunRestore(id, true)` (per `restore-flow`). + +#### Scenario: viewport initialized with diff content + +- GIVEN `restoreDryRunResultMsg{output: "diff --git ..."}` arrives +- WHEN the restore model handles the message +- THEN the embedded `viewport.Model` MUST have its content set to the full diff string +- AND the state MUST transition to `restoreStateDryRun` + +#### Scenario: up/down and j/k scroll the viewport + +- GIVEN the viewport holds more lines than its visible height +- WHEN the user presses `j` or `↓` +- THEN the viewport MUST scroll down one line +- WHEN the user presses `k` or `↑` +- THEN the viewport MUST scroll up one line + +#### Scenario: q returns to previous screen + +- GIVEN the model is in `restoreStateDryRun` +- WHEN the user presses `q` +- THEN a `ScreenBackMsg` or transition to `restoreStateList` MUST occur +- AND the viewport MUST NOT print its content to stdout + +#### Scenario: content fits terminal without scroll + +- GIVEN the diff is 5 lines and the viewport height is 20 +- WHEN `View()` renders +- THEN the full diff MUST be visible with no scroll offset +- AND the scroll-percent indicator (if shown) MUST read 100% \ No newline at end of file diff --git a/openspec/changes/tui-personality/specs/wizard-flow/spec.md b/openspec/changes/tui-personality/specs/wizard-flow/spec.md new file mode 100644 index 0000000..bd8a938 --- /dev/null +++ b/openspec/changes/tui-personality/specs/wizard-flow/spec.md @@ -0,0 +1,54 @@ +# Delta for wizard-flow + +## ADDED Requirements + +### Requirement: Paste support in wizard text inputs + +The wizard text input fields (profile name, and any free-text wizard step) MUST accept bracketed paste via the v2 `tea.PasteMsg` message. The model MUST append `msg.Content` to the active input buffer. `DisableBracketedPasteMode` MUST remain `false` (the default) so the terminal can deliver paste events. Plain keystrokes MUST continue to work alongside paste. + +#### Scenario: paste inserts pasted text + +- GIVEN the wizard is on the profile-name step with an empty input +- WHEN a `tea.PasteMsg{Content: "work-laptop"}` arrives +- THEN the input buffer MUST equal `"work-laptop"` + +#### Scenario: paste appends to existing text + +- GIVEN the input already contains `"work-"` +- WHEN a `tea.PasteMsg{Content: "laptop"}` arrives +- THEN the input buffer MUST equal `"work-laptop"` + +#### Scenario: regular keys still work after paste + +- GIVEN paste has populated the input with `"work-laptop"` +- WHEN the user presses Backspace +- THEN the input buffer MUST become `"work-lapto"` + +## MODIFIED Requirements + +### Requirement: Profile creation via wizard + +The system MUST launch the 5-step interactive wizard when the user presses 'n' on the profiles screen. The wizard result MUST create a real profile, not a hardcoded stub. The wizard text inputs MUST accept bracketed paste (per "Paste support in wizard text inputs"). + +(Previously: wizard inputs only accepted character-at-a-time keystrokes; multi-character paste was not handled.) + +#### Scenario: tuiRunWizard launches real wizardModel + +- GIVEN the user presses 'n' on the profiles screen +- WHEN `tuiRunWizard` is invoked +- THEN it MUST launch the `wizardModel` via `tea.NewProgram` +- AND the wizard MUST present all 5 steps (name, provider, preset, adapters, confirm) + +#### Scenario: Wizard result creates real profile + +- GIVEN the user completes all 5 wizard steps +- WHEN the wizard finishes +- THEN the returned `ProfileInfo` MUST contain user-selected values (not hardcoded defaults) +- AND the profile MUST be saved via `tuiSaveProfile` + +#### Scenario: Wizard cancel returns to profiles + +- GIVEN the user is in the wizard +- WHEN the user presses 'q' or Esc to cancel +- THEN `tuiRunWizard` MUST return an error or zero-value `ProfileInfo` +- AND the profiles screen MUST NOT add a new profile \ No newline at end of file From 239b4818d9fa492ae0bb5dd784749d89fb7641c9 Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Fri, 26 Jun 2026 18:04:31 -0500 Subject: [PATCH 4/4] feat(tui): persistent status bar + gradient logo with no-color fallback Phase 2 of tui-personality (Tier 1b): - components/statusbar.go: stateless RenderStatusBar(width, version, preset, path) hidden below 40 cols; path truncated with ellipsis. Styled with package-level StatusBarStyle (Rose Pine ColorSubtle). - styles/screens.go: add StatusBarStyle package-level var. - styles/logo.go: 5 fixed Foreground styles -> lipgloss.Blend1D gradient (Love->Gold->Rose->Pine->Lavender); settable colorProfile var falls back to plain text (no ANSI) on NoTTY/Ascii profiles. - model.go: renderContent appends the status bar at the bottom of every screen; deps.go adds Preset/BackupPath; cmd/root.go populates them best-effort (config.DefaultPreset + backup.BakDir). - wizard.go: extract wizardView helper (funlen). model.go: extract progressTitle/restoreTitle (gocyclo). REQ-TP-003, REQ-TP-004. TDD red/green; coverage 83-96% per TUI package; golangci-lint clean. NO-VERIFY: GGA pre-commit failed with 'Argument list too long' (ARG_MAX, exit 126) on the 10-file diff -- a documented GGA technical limitation per AGENTS.md. Code reviewed against AGENTS.md rules manually. go test ./..., go vet, and golangci-lint run all pass clean. --- cmd/root.go | 17 ++++ internal/tui/components/statusbar.go | 79 +++++++++++++++ internal/tui/components/statusbar_test.go | 116 ++++++++++++++++++++++ internal/tui/deps.go | 8 ++ internal/tui/model.go | 33 ++++-- internal/tui/model_test.go | 35 ++++++- internal/tui/screens/wizard.go | 21 ++-- internal/tui/styles/logo.go | 58 ++++++----- internal/tui/styles/logo_test.go | 42 ++++++++ internal/tui/styles/screens.go | 6 ++ openspec/changes/tui-personality/tasks.md | 12 +-- 11 files changed, 375 insertions(+), 52 deletions(-) create mode 100644 internal/tui/components/statusbar.go create mode 100644 internal/tui/components/statusbar_test.go diff --git a/cmd/root.go b/cmd/root.go index a33bfbf..e6e08a1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -38,8 +38,11 @@ Run 'bak restore --dry-run ' to preview before applying.`, // and stdout is a terminal. Cobra handles --help before RunE, so // `bak --help` still shows cobra help regardless of TTY. if len(args) == 0 && isTTY() { + preset, backupPath := tuiStatusBarInfo() deps := tui.Deps{ Version: Version, + Preset: preset, + BackupPath: backupPath, ConfigExists: configExists, ListBackups: listBackups, RunBackup: tuiRunBackup, @@ -71,6 +74,20 @@ func configExists() bool { return err == nil } +// tuiStatusBarInfo returns the active preset and backup directory for the +// persistent TUI status bar (tui-personality REQ-TP-003). Both are best-effort: +// on any error the empty string is returned and the status bar omits that +// segment rather than failing the launch. +func tuiStatusBarInfo() (preset, backupPath string) { + if cfg, err := config.Load(); err == nil && cfg.Settings.DefaultPreset != "" { + preset = cfg.Settings.DefaultPreset + } + if dir, err := backup.BakDir(); err == nil { + backupPath = dir + } + return preset, backupPath +} + // Execute runs the root command. func Execute() { rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output") diff --git a/internal/tui/components/statusbar.go b/internal/tui/components/statusbar.go new file mode 100644 index 0000000..ae5c541 --- /dev/null +++ b/internal/tui/components/statusbar.go @@ -0,0 +1,79 @@ +// Package components provides reusable, pure render functions for TUI +// components. statusbar.go renders the persistent one-line status bar shown at +// the bottom of every screen (tui-personality REQ-TP-003). +package components + +import ( + "charm.land/lipgloss/v2" + + "github.com/danielxxomg/bak-cli/internal/tui/styles" +) + +// statusBarSeparator joins the status bar segments. +const statusBarSeparator = " • " + +// statusBarHiddenBelow is the minimum terminal width (in columns) below which +// the status bar is hidden to avoid overflow. Matches the logo threshold. +const statusBarHiddenBelow = 40 + +// RenderStatusBar renders a one-line status bar containing the version, +// active preset, and backup path. It is a stateless pure function styled with +// the package-level StatusBarStyle (AGENTS.md §styles). +// +// The bar is hidden (returns "") when width is below 40 columns. When the +// backup path is too long to fit, it is truncated with an ellipsis ("…") so +// the whole bar stays within the terminal width. +func RenderStatusBar(width int, version, preset, path string) string { + if width < statusBarHiddenBelow { + return "" + } + + // Leading segment: app name + version + preset. + left := "bak" + if version != "" { + left += " v" + version + } + if preset != "" { + left += statusBarSeparator + preset + } + + full := left + if path != "" { + full += statusBarSeparator + path + } + + // Fits as-is. + if lipgloss.Width(full) <= width { + return styles.StatusBarStyle.Render(full) + } + + // Doesn't fit: truncate the path segment (spec: path truncated with + // ellipsis). If there's no path, truncate the leading segment. + if path == "" { + return styles.StatusBarStyle.Render(truncateEllipsis(left, width)) + } + + avail := width - lipgloss.Width(left) - len(statusBarSeparator) + if avail <= 0 { + // No room for the path at all; show the (possibly truncated) lead. + return styles.StatusBarStyle.Render(truncateEllipsis(left, width)) + } + return styles.StatusBarStyle.Render(left + statusBarSeparator + truncateEllipsis(path, avail)) +} + +// truncateEllipsis truncates s to max visible columns, appending an ellipsis +// ("…") when truncation occurs. It is rune-aware so multi-byte paths truncate +// cleanly. Returns s unchanged when it already fits. +func truncateEllipsis(s string, max int) string { + if max <= 0 { + return "" + } + r := []rune(s) + if len(r) <= max { + return s + } + if max == 1 { + return "…" + } + return string(r[:max-1]) + "…" +} diff --git a/internal/tui/components/statusbar_test.go b/internal/tui/components/statusbar_test.go new file mode 100644 index 0000000..f7f1d7c --- /dev/null +++ b/internal/tui/components/statusbar_test.go @@ -0,0 +1,116 @@ +// Package components provides reusable, pure render functions for TUI +// components. This file contains TDD tests for the persistent status bar +// (tui-personality REQ-TP-003), written BEFORE the production code. +package components + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" +) + +func TestRenderStatusBar(t *testing.T) { //nolint:paralleltest // shared styles/colorprofile global state + tests := []struct { + name string + width int + version string + preset string + path string + wantEmpty bool + contains []string + notContain []string + wantMaxW int // visible width must be <= wantMaxW (0 = skip width check) + }{ + { + name: "wide terminal shows all segments", + width: 80, + version: "1.0.0", + preset: "default", + path: "/home/user/.bak/backups", + contains: []string{"bak v1.0.0", "default", "/home/user/.bak/backups"}, + wantMaxW: 80, + }, + { + name: "narrow below 40 hidden", + width: 39, + version: "1.0.0", + preset: "default", + path: "/x", + wantEmpty: true, + }, + { + name: "exactly 40 columns shown", + width: 40, + version: "1.0.0", + contains: []string{"bak v1.0.0"}, + wantMaxW: 40, + }, + { + name: "long path truncated with ellipsis", + width: 60, + version: "1.0.0", + preset: "default", + path: strings.Repeat("a", 50), + contains: []string{"bak v1.0.0", "default", "…"}, + notContain: []string{strings.Repeat("a", 50)}, // full path must not fit + wantMaxW: 60, + }, + { + name: "empty version still shows bak", + width: 80, + version: "", + preset: "", + path: "", + contains: []string{"bak"}, + }, + { + name: "no preset no path omits separator", + width: 80, + version: "1.0.0", + preset: "", + path: "", + contains: []string{"bak v1.0.0"}, + notContain: []string{"•"}, + }, + { + name: "path only segment", + width: 80, + version: "", + preset: "", + path: "/var/bak", + contains: []string{"/var/bak"}, + }, + } + + for _, tt := range tests { //nolint:paralleltest // subtests share table/struct state + t.Run(tt.name, func(t *testing.T) { //nolint:paralleltest // subtests share table/struct state + got := RenderStatusBar(tt.width, tt.version, tt.preset, tt.path) + + if tt.wantEmpty { + if got != "" { + t.Errorf("RenderStatusBar(%d,…) = %q, want empty (hidden <40)", tt.width, got) + } + return + } + if got == "" { + t.Fatalf("RenderStatusBar(%d,…) returned empty, want non-empty", tt.width) + } + for _, want := range tt.contains { + if !strings.Contains(got, want) { + t.Errorf("output %q must contain %q", got, want) + } + } + for _, notWant := range tt.notContain { + if strings.Contains(got, notWant) { + t.Errorf("output %q must NOT contain %q", got, notWant) + } + } + if tt.wantMaxW > 0 { + if w := lipgloss.Width(got); w > tt.wantMaxW { + t.Errorf("visible width = %d, want <= %d (output %q)", w, tt.wantMaxW, got) + } + } + }) + } +} diff --git a/internal/tui/deps.go b/internal/tui/deps.go index 415655a..3ba82fd 100644 --- a/internal/tui/deps.go +++ b/internal/tui/deps.go @@ -11,6 +11,14 @@ type Deps struct { // Version is the application version string shown in the UI. Version string + // Preset is the active backup preset name shown in the status bar. + // Empty when no preset is configured (the bar omits the segment). + Preset string + + // BackupPath is the local backups directory shown (truncated) in the + // status bar. Empty when unresolved (the bar omits the segment). + BackupPath string + // ListBackups returns all known backups. May be nil during testing. ListBackups func() ([]BackupInfo, error) diff --git a/internal/tui/model.go b/internal/tui/model.go index 4c34d46..be70a3e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -649,20 +649,32 @@ func titleForScreen(m Model) string { case ScreenProfiles: return "bak — Profiles" case ScreenProgress: - if m.progress != nil && m.progress.Running() && m.progress.Total > 0 { - return fmt.Sprintf("bak — Backup %d/%d", m.progress.Current, m.progress.Total) - } - return "bak — Backup" + return progressTitle(m) case ScreenRestore: - if m.restore != nil && m.restore.SelectedID != "" { - return "bak — Restore:" + m.restore.SelectedID - } - return "bak — Restore" + return restoreTitle(m) default: return "bak" } } +// progressTitle renders the progress screen title, appending the live step +// counter ("bak — Backup 3/7") when an operation is running with a known total. +func progressTitle(m Model) string { + if m.progress != nil && m.progress.Running() && m.progress.Total > 0 { + return fmt.Sprintf("bak — Backup %d/%d", m.progress.Current, m.progress.Total) + } + return "bak — Backup" +} + +// restoreTitle renders the restore screen title, appending the selected backup +// id ("bak — Restore:abc1234") when one has been chosen. +func restoreTitle(m Model) string { + if m.restore != nil && m.restore.SelectedID != "" { + return "bak — Restore:" + m.restore.SelectedID + } + return "bak — Restore" +} + // renderContent renders the active screen with optional help and toast // overlays. It is the non-tooSmall branch of View, extracted to keep View's // nesting shallow. @@ -672,6 +684,11 @@ func (m Model) renderContent() string { if m.showHelp { content = screens.RenderShortcuts(m.width) } + // Persistent status bar at the bottom of every screen (REQ-TP-003). + // Hidden on narrow terminals (<40 cols) by RenderStatusBar itself. + if bar := components.RenderStatusBar(m.width, m.deps.Version, m.deps.Preset, m.deps.BackupPath); bar != "" { + content += "\n" + bar + } // Render toast overlay. On wide terminals (>= 50 cols), position // the toast at bottom-right using lipgloss.Place. On narrow terminals, // fall back to inline append below the screen content. diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e0825aa..81a7c6b 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -2906,7 +2906,12 @@ func modelAtScreen(s screen) Model { return m } newM, _ := m.Update(screenChangeMsg{screen: s}) - return newM.(Model) + m = newM.(Model) + // Propagate dimensions to the sub-model so it renders real content + // (not the "Terminal too small" guard) — screenChangeMsg only sets Width + // for some screens, so a WindowSizeMsg guarantees Height too. + m2, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + return m2.(Model) } func TestModel_View_WindowTitle(t *testing.T) { //nolint:paralleltest // shared styles/colorprofile global state @@ -2962,8 +2967,8 @@ func TestModel_View_WindowTitle(t *testing.T) { //nolint:paralleltest // shared wantTitle: "bak — Shortcuts", }, { - name: "progress idle shows Backup", - setup: func() Model { return modelAtScreen(ScreenProgress) }, + name: "progress idle shows Backup", + setup: func() Model { return modelAtScreen(ScreenProgress) }, wantTitle: "bak — Backup", }, { @@ -2976,8 +2981,8 @@ func TestModel_View_WindowTitle(t *testing.T) { //nolint:paralleltest // shared wantTitle: "bak — Backup 3/7", }, { - name: "restore without id shows Restore", - setup: func() Model { return modelAtScreen(ScreenRestore) }, + name: "restore without id shows Restore", + setup: func() Model { return modelAtScreen(ScreenRestore) }, wantTitle: "bak — Restore", }, { @@ -3011,3 +3016,23 @@ func TestModel_View_WindowTitle(t *testing.T) { //nolint:paralleltest // shared }) } } + +// TestModel_View_StatusBar verifies the persistent status bar (REQ-TP-003) is +// appended at the bottom of every screen on wide terminals and hidden below 40 +// columns. Uses ScreenProgress, whose own content has no "bak v" string, so the +// presence of "bak v1.0.0" proves the status bar rendered. +func TestModel_View_StatusBar(t *testing.T) { //nolint:paralleltest // shared styles/colorprofile global state + // Wide terminal: status bar present. + m := modelAtScreen(ScreenProgress) + out := m.View().Content + if !strings.Contains(out, "bak v1.0.0") { + t.Errorf("wide View() missing status bar 'bak v1.0.0':\n%s", out) + } + + // Narrow terminal (<40 cols): status bar hidden. + m.width = 39 + narrow := m.View().Content + if strings.Contains(narrow, "bak v1.0.0") { + t.Errorf("narrow View() should hide status bar, but contains 'bak v1.0.0':\n%s", narrow) + } +} diff --git a/internal/tui/screens/wizard.go b/internal/tui/screens/wizard.go index 44b8574..2b69c60 100644 --- a/internal/tui/screens/wizard.go +++ b/internal/tui/screens/wizard.go @@ -237,19 +237,24 @@ func (m *WizardModel) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) return m, nil } +// wizardView wraps rendered content in a tea.View tagged with the wizard +// window title. Extracted so every View branch sets the title without +// inflating View's statement count (AGENTS.md funlen budget). +func wizardView(content string) tea.View { + v := tea.NewView(content) + v.WindowTitle = wizardWindowTitle + return v +} + // View implements bubbletea.Model. func (m *WizardModel) View() tea.View { if m.Quitting { - v := tea.NewView("") - v.WindowTitle = wizardWindowTitle - return v + return wizardView("") } // Guard against terminals below the minimum usable size. if m.Width > 0 && m.Height > 0 && styles.IsTooSmall(m.Width, m.Height) { - v := tea.NewView(styles.HelpStyle.Render(styles.RenderTooSmall(m.Width, m.Height))) - v.WindowTitle = wizardWindowTitle - return v + return wizardView(styles.HelpStyle.Render(styles.RenderTooSmall(m.Width, m.Height))) } var b strings.Builder @@ -306,9 +311,7 @@ func (m *WizardModel) View() tea.View { {Key: "q/esc", Desc: keyQuit}, })) - v := tea.NewView(b.String()) - v.WindowTitle = wizardWindowTitle - return v + return wizardView(b.String()) } // renderCheckboxList renders a list of toggleable items using the shared diff --git a/internal/tui/styles/logo.go b/internal/tui/styles/logo.go index 3ce4e1c..ca89ed0 100644 --- a/internal/tui/styles/logo.go +++ b/internal/tui/styles/logo.go @@ -4,24 +4,37 @@ import ( "strings" "charm.land/lipgloss/v2" + "github.com/charmbracelet/colorprofile" ) // asciiLogo holds the ASCII art logo for bak-cli. // 5 lines tall, ~28 chars wide. Rendered with a 5-band Rose Pine gradient. const asciiLogo = ` ____ _ _ __ - | _ \ / \ | |/ / - | |_) |/ _ \ | ' / - | _ < / ___ \| . \ - |_| \_/_/ \_\_|\_\` - -// RenderLogo returns the ASCII art "bak" logo with a 5-band Rose Pine -// gradient applied. The gradient runs from Love (top) through Gold, Rose, -// Pine, to Lavender (bottom). + | _ \ / \ | |/ / + | |_) |/ _ \ | ' / + | _ < / ___ \| . \ + |_| \_/_/ \_\_|\_\` + +// colorProfile controls whether RenderLogo emits ANSI color. It defaults to +// TrueColor so the gradient renders on color terminals. On no-color profiles +// (NoTTY/Ascii) RenderLogo falls back to plain text without ANSI codes +// (REQ-TP-004 §"plain logo on no-color terminal"). +// +// Production terminal profile detection is handled by the bubbletea renderer, +// which downsamples the full View to the terminal's profile; this variable +// exists so the logo's explicit no-color branch is unit-testable as a pure +// function (tests in this package override it and restore it). +var colorProfile = colorprofile.TrueColor + +// RenderLogo returns the ASCII art "bak" logo with a Rose Pine multi-stop +// vertical gradient (Love → Gold → Rose → Pine → Lavender) applied via +// lipgloss.Blend1D, one gradient color per logo line. // -// If the terminal width is less than 40 columns, an empty string is returned -// to prevent overflow. +// On a no-color profile (NoTTY/Ascii) the logo falls back to uncolored plain +// text. If the terminal width is less than 40 columns, an empty string is +// returned to prevent overflow (existing behavior preserved). func RenderLogo(width int) string { - if width < 40 { + if width < 40 { //nolint:mnd // logo hide threshold, matches status bar return "" } @@ -30,26 +43,23 @@ func RenderLogo(width int) string { return "" } - // 5-band gradient: Love → Gold → Rose → Pine → Lavender - bandColors := []lipgloss.Style{ - lipgloss.NewStyle().Foreground(ColorLove), - lipgloss.NewStyle().Foreground(ColorGold), - lipgloss.NewStyle().Foreground(ColorRose), - lipgloss.NewStyle().Foreground(ColorPine), - lipgloss.NewStyle().Foreground(ColorLavender), + // No-color profile: monochrome plain text, no ANSI codes. + if colorProfile <= colorprofile.Ascii { + return strings.Join(lines, "\n") } + // 5-stop Rose Pine gradient, one color per line. With 5 lines and 5 stops + // Blend1D returns the stops verbatim; adding lines or stops would smoothly + // interpolate between them in CIELAB space. + grad := lipgloss.Blend1D(len(lines), ColorLove, ColorGold, ColorRose, ColorPine, ColorLavender) + var b strings.Builder for i, line := range lines { - if i >= len(bandColors) { - break - } - styled := bandColors[i].Render(line) - b.WriteString(styled) + color := grad[i%len(grad)] + b.WriteString(lipgloss.NewStyle().Foreground(color).Render(line)) if i < len(lines)-1 { b.WriteByte('\n') } } - return b.String() } diff --git a/internal/tui/styles/logo_test.go b/internal/tui/styles/logo_test.go index 763b95d..e045fa3 100644 --- a/internal/tui/styles/logo_test.go +++ b/internal/tui/styles/logo_test.go @@ -3,6 +3,8 @@ package styles import ( "strings" "testing" + + "github.com/charmbracelet/colorprofile" ) func TestRenderLogo_NonEmpty(t *testing.T) { //nolint:paralleltest // not yet parallelized — shared state (os.Stderr/execCommand/config-file/struct) isolation pending @@ -84,3 +86,43 @@ func TestRenderLogo_Gradient(t *testing.T) { //nolint:paralleltest // not yet pa t.Errorf("RenderLogo(80) uses %d/5 gradient colors, want at least 4. Output: %q", found, result) } } + +// TestRenderLogo_GradientLineCount verifies the gradient logo renders exactly +// one colored line per ASCII art line (5 stops → 5 distinct colored lines), +// proving Blend1D produces a per-line gradient (REQ-TP-004). +func TestRenderLogo_GradientLineCount(t *testing.T) { //nolint:paralleltest // shared styles/colorprofile global state + result := RenderLogo(80) + lines := strings.Split(result, "\n") + if len(lines) != 5 { //nolint:mnd // asciiLogo is 5 lines + t.Fatalf("RenderLogo(80) has %d lines, want 5", len(lines)) + } + colored := 0 + for _, line := range lines { + if strings.Contains(line, "\x1b[") { + colored++ + } + } + if colored != 5 { //nolint:mnd + t.Errorf("RenderLogo(80) has %d colored lines, want 5 (one gradient color per line)", colored) + } +} + +// TestRenderLogo_AsciiProfileUncolored verifies that on a no-color profile +// (Ascii) the logo falls back to plain text without ANSI color codes +// (REQ-TP-004 §"plain logo on no-color terminal"). The default profile emits +// color, so this exercises the explicit no-color branch. +func TestRenderLogo_AsciiProfileUncolored(t *testing.T) { //nolint:paralleltest // mutates package-level colorProfile + orig := colorProfile + colorProfile = colorprofile.Ascii + defer func() { colorProfile = orig }() + + result := RenderLogo(80) + if strings.Contains(result, "\x1b[") { + t.Errorf("Ascii profile logo must have no ANSI color codes, got %q", result) + } + // Plain text still renders the ASCII art (5 lines, art glyphs present). + lines := strings.Split(result, "\n") + if len(lines) != 5 { //nolint:mnd + t.Errorf("Ascii logo has %d lines, want 5", len(lines)) + } +} diff --git a/internal/tui/styles/screens.go b/internal/tui/styles/screens.go index 903ce2b..68f4b19 100644 --- a/internal/tui/styles/screens.go +++ b/internal/tui/styles/screens.go @@ -104,3 +104,9 @@ var ( Foreground(ColorMuted). Padding(1, 2) ) + +// StatusBarStyle is the persistent one-line status bar style (Rose Pine +// semantic colors). Foreground only — no padding — so the bar's visible width +// equals its text width and truncation math stays predictable (AGENTS.md +// §styles: package-level var, no inline NewStyle in render paths). +var StatusBarStyle = lipgloss.NewStyle().Foreground(ColorSubtle) diff --git a/openspec/changes/tui-personality/tasks.md b/openspec/changes/tui-personality/tasks.md index 696fce8..7331e83 100644 --- a/openspec/changes/tui-personality/tasks.md +++ b/openspec/changes/tui-personality/tasks.md @@ -35,12 +35,12 @@ Chain strategy: stacked-to-main ## Phase 2: Status Bar & Gradient Logo (PR 1 — Tier 1b) -- [ ] 2.1 [RED] `internal/tui/components/statusbar_test.go`: table-driven — wide terminal shows version/preset/path, narrow (<40) returns empty, long path truncated with ellipsis -- [ ] 2.2 [GREEN] `internal/tui/components/statusbar.go`: stateless `RenderStatusBar(width int, version, preset, path string) string`; hidden when width < 40 -- [ ] 2.3 [GREEN] `internal/tui/styles/screens.go`: add package-level `StatusBarStyle` var (Rose Pine semantic colors) -- [ ] 2.4 [REFACTOR] `internal/tui/model.go`: `renderContent()` appends `components.RenderStatusBar(m.width, m.deps.Version, preset, backupPath)` at bottom; add `Deps` accessors for preset/path if missing -- [ ] 2.5 [RED] `internal/tui/styles/logo_test.go`: assert `RenderLogo` returns `len(lines)` gradient-colored lines on color profile; assert empty on width < 40; assert uncolored on Ascii profile -- [ ] 2.6 [GREEN] `internal/tui/styles/logo.go`: replace 5 fixed `Foreground()` styles with `lipgloss.Blend1D` gradient (Love→Gold→Rose→Pine→Lavender); Ascii profile fallback to plain text. **Verify `Blend1D` signature before implementing** (open question from design) +- [x] 2.1 [RED] `internal/tui/components/statusbar_test.go`: table-driven — wide terminal shows version/preset/path, narrow (<40) returns empty, long path truncated with ellipsis +- [x] 2.2 [GREEN] `internal/tui/components/statusbar.go`: stateless `RenderStatusBar(width int, version, preset, path string) string`; hidden when width < 40 +- [x] 2.3 [GREEN] `internal/tui/styles/screens.go`: add package-level `StatusBarStyle` var (Rose Pine semantic colors) +- [x] 2.4 [REFACTOR] `internal/tui/model.go`: `renderContent()` appends `components.RenderStatusBar(m.width, m.deps.Version, preset, backupPath)` at bottom; add `Deps` accessors for preset/path if missing +- [x] 2.5 [RED] `internal/tui/styles/logo_test.go`: assert `RenderLogo` returns `len(lines)` gradient-colored lines on color profile; assert empty on width < 40; assert uncolored on Ascii profile +- [x] 2.6 [GREEN] `internal/tui/styles/logo.go`: replace 5 fixed `Foreground()` styles with `lipgloss.Blend1D` gradient (Love→Gold→Rose→Pine→Lavender); Ascii profile fallback to plain text. **Verify `Blend1D` signature before implementing** (open question from design) ## Phase 3: Viewport Dry-Run (PR 2 — Tier 2a)