Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
# Compiled binary (built from source; never commit build artifacts)
/tailtui

# Test/coverage output
*.test
*.out
coverage.*

# VHS demo renders are committed under assets/ deliberately; ignore stray ones
*.gif.tmp

# Editor / OS noise
.DS_Store
*.swp
*~
.idea/
.vscode/

# Design sources: keep only the two specs the repo documents
_designs/*
!_designs/00_STYLE_GUIDE.md
!_designs/00_WIREFRAME_SPEC.md
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ Before changing any interaction, consult the keybinding matrix and overlay specs
- **Phase 26.1 — VHS tape syntax correction & README hero-image swap** — docs-only follow-up to Phase 26. **(1) Tape correction**: the `Set Env TAILTUI_MOCK 1` directive added to `demo.tape` in Phase 26 is **not valid VHS syntax** and was removed; the env var must travel via the invoking shell instead (**`TAILTUI_MOCK=1 vhs demo.tape`**). The tape's header comment was updated to document the correct invocation and explicitly note that `Set Env` is intentionally **not** used. Going forward, do **not** reintroduce `Set Env TAILTUI_MOCK 1` to the tape. **(2) README hero image**: the static ASCII terminal mockup block that lived directly under the introductory paragraph was replaced with the live demo image tag `![tailTUI Live Demo](assets/demo.gif)`, pointing at the GIF produced by `vhs demo.tape`. Generating `assets/demo.gif` is a separate manual step (run VHS locally with the mock env var); the README now expects that file to exist when the repository is published. No Go / test changes — markdown + tape comment only.
- **Phase 26.2 — Privacy clean-up: pseudonym-only attribution** — anonymized every personal-name reference across the repo ahead of publication. Three text touches: **LICENSE** copyright holder line now reads `Copyright (c) 2026 Phundahl`; **README.md**'s Acknowledgments paragraph and License footer both attribute the project to `Phundahl`; **CLAUDE.md**'s Phase 16 log entry parenthetical was updated to match. After the sweep, a case-insensitive repo-wide grep for the prior real-name fragments returns only the GitHub-username import path `github.com/Phundahl/tailtui` (the public handle, which is supposed to stay). **Standing rule going forward — strict pseudonym enforcement**: the only attribution form anywhere in this codebase is `Phundahl`. No first / full / real names in source-file headers, code comments, docstrings, markdown docs, the phase log, or commit messages — including when *describing* a fix that anonymized one (paraphrase the change instead of quoting the old value). No Go / test / functional changes this phase — text-only.
- **Phase 27 — Omarchy 4 ("Quattro") theme support: dual location, dual schema & light mode** — the theme loader had gone silently dead on upgraded machines. Omarchy 4 changed **both** halves of the contract: the current-theme symlink moved `~/.config/omarchy/current/theme/` → `~/.local/state/omarchy/current/theme/`, and `colors.toml` swapped its flat terminal palette (`color0`–`color15`) for **semantic slots** (`mode`, `selection`, `muted`, `lighter_background`, `dark_background`, `red`/`yellow`/`orange`/`green`, …). `LoadTheme` found nothing, fell back to Matrix Core without a word, and the UI stopped tracking the desktop. **(1) Path probing**: `themeCandidates()` returns the v4 location then the legacy one (or a lone `TAILTUI_THEME` override); `LoadTheme` walks them and takes the first that yields a usable palette, and `ThemePath()` now reports the file actually read rather than a hardcoded guess. **(2) Schema detection**: `omarchyV4` and `omarchyLegacy` wire structs each expose `hasMarkers()`, checking only the keys **exclusive** to that generation — the three shared keys (`accent`/`foreground`/`background`) are deliberately excluded so they can't misclassify a file; detection is by content, not filename, so an override of either vintage maps correctly. A file with only the shared three still applies them and defaults the rest. **(3) Light mode**: new `Theme.Mode` field (`ModeDark`/`ModeLight`) read from the v4 `mode` key, used to flip which slot becomes `Surface` — `lighter_background` when dark, `dark_background` when light — because the elevated surface has to move away from the canvas in whichever direction the canvas sits. Light themes (`catppuccin-latte`, `flexoki-light`, `white`) previously rendered as an inverted mess. **(4) Warning slot**: `yellow`, falling back to `orange` for the themes that omit it. **Tests** (`internal/styles/theme_test.go`, the package's first): verbatim fixtures from real shipped themes cover v4 dark, v4 light (asserting `Surface` takes `dark_background` and *not* `lighter_background`), the legacy schema, orange fallback, partial/ambiguous files, malformed/empty/missing files, and the path-priority + override rules; `TestInstalledOmarchyThemesMapCompletely` sweeps every `colors.toml` installed on the machine (22 themes) and fails if **any** palette slot is empty or silently equal to its Matrix Core default — the tripwire for the next upstream schema change. It skips cleanly where Omarchy isn't installed. No TUI / layout / keybinding changes; `styles.Apply` and every consumer are untouched.
- **Phase 28 — CI workflow & release-config repair** — `.github/workflows/release.yml` was the **only** workflow and it fires solely on `v*` tags, so nothing verified a commit until a release was already being cut, and a broken `.goreleaser.yaml` could only surface as a failed release job *after* the tag existed. Added `.github/workflows/ci.yml`: runs on pushes to `main`, on every pull request, and via `workflow_dispatch`; `concurrency` cancels superseded in-flight runs; `contents` permission is read-only. Two jobs — **`test`** (a `gofmt -l` gate, a `go mod tidy` drift gate, `go build ./...`, `go vet ./...`, and **`go test -race ./...`**; the View layer is TTY-free so the suite runs headless, and `-race` covers the async `tea.Cmd` status/ping polling paths) and **`goreleaser-config`** (`goreleaser check`, so config errors fail on a PR instead of on a tag). Both resolve Go via `go-version-file: go.mod` rather than `stable`, so CI tracks the pinned toolchain instead of drifting on release day. **The new check immediately caught three real defects** in `.goreleaser.yaml`, every one of which would have failed the release job only after a tag was pushed: `nfpms.files` → **`nfpms.contents`** (`files` is a v1 key and is rejected outright by the v2 schema — the hard failure); `archives.format` → **`archives.formats`** (deprecated singular); and `snapshot.name_template` → **`snapshot.version_template`** (deprecated — note `goreleaser check` exits non-zero on deprecations, not just schema errors). Also dropped `-extldflags "{{.Env.LDFLAGS}}"` from the build ldflags — it required an `LDFLAGS` env var to be set at release time and is a no-op under `CGO_ENABLED=0` (no external linker to pass it to) — and translated the one non-English comment. **Test hermeticity is a requirement for CI to stay green**: every styles test uses `t.Setenv` for `HOME`/`TAILTUI_THEME`, and `TestInstalledOmarchyThemesMapCompletely` (which globs the machine's real Omarchy theme dir) **skips** when none is installed. Don't add a test that assumes Omarchy, a TTY, or a live `tailscale` daemon without an equivalent skip guard.
- **Phase 29 — Release v1.2.0 & version plumbing** — cut the release covering everything since the (never-published) `v1.1.0` tag: the 24.x sudo account flows, Phase 26 mock mode + VHS demo, the 26.2 privacy pass, Phase 27's Omarchy 4 theme support, and Phase 28's CI. **Version plumbing fix**: `.goreleaser.yaml` stamps `-X main.version={{.Version}}`, but `main.go` had **no `version` var** and `appVersion` was a `const` in `internal/tui/view.go` — so the ldflag silently did nothing (Go ignores `-X` on a missing symbol) and a tagged build would have shipped whatever was hardcoded. Now `main.go` declares `var version string` (empty for `go build`/`go run`) and calls **`tui.SetVersion(version)`** first thing in `main`; `appVersion` became a **var** (dev-build literal `v1.2.0`) and `SetVersion` ignores an empty value and normalizes a bare `1.2.0` → `v1.2.0`, since goreleaser's `{{.Version}}` drops the prefix. `appVersion` remains the single definition — set it there, never duplicate it. Added `internal/tui/version_test.go` (normalization table incl. prerelease + empty, plus a render test asserting the stamped version actually reaches the footer while staying flush). `.gitignore` was rewritten to cover the compiled `/tailtui` binary (previously **committed**, ~6.5 MB, removed from history in the same cycle), test/coverage output, and editor/OS noise, while preserving the existing `_designs/` allow-list. README gained a `ci` status badge under the tagline and a "What's New in v1.2.0" section above the v1.1.0 one. No layout / state / keybinding changes.
- **Upcoming (next major cycle — see the README Roadmap)** —
- **Tailscale Serve & Funnel management**: visual port forwarding to securely expose local services to the tailnet (`tailscale serve`) or the public internet (`tailscale funnel`), driven from keyboard overlays in the existing modal style.
- **Connection diagnostics**: deep-dive into peer connection health — DERP-relay vs. direct routing and the signals to debug a flaky link (likely from `tailscale status --json` endpoints + `tailscale ping`/`netcheck`).
Expand Down Expand Up @@ -213,6 +215,18 @@ Inside an overlay, `esc`/`q` close it (except the Routing CIDR editor — see be

Keys from the design spec not yet wired: `p` (ping-as-action) and `s` (SSH-as-action — lowercase `s` is reserved and currently a no-op; Advanced Settings moved to uppercase `S`).

## Git workflow (required)

Adopted in the v1.2.0 cycle; `main` is a published, public branch.

- **Never commit directly to `main`.** Branch first: `feat/…`, `fix/…`, `ci/…`, `chore/…`, `docs/…`. Keep branches short-lived and single-purpose.
- **Every change lands through a pull request**, squash-merged, branch deleted. CI (`test` + `goreleaser-config`) gates the merge — see Phase 28.
- **Rebase onto the latest `main` before opening a PR**, so the PR is tested against current `main` rather than a stale base.
- **Order dependent work so the safety net lands first.** CI went in ahead of the feature PRs precisely so they were gated by it.
- **Scope a PR to files it truly needs.** When several changes touch the same doc (the phase log especially), split by replaying the edits in phase order across sequential branches rather than hunk-splitting one big diff.
- **Releases are tags on `main`** (`git tag -a vX.Y.Z && git push origin vX.Y.Z`), which is the only trigger for `release.yml`. Bump `appVersion` in `view.go` in the release PR, before tagging.
- **Commit identity is the pseudonym only** — `Phundahl <89451493+Phundahl@users.noreply.github.com>`. Never let a real-name or employer-domain address into commit metadata; this is the attribution rule above, applied to Git itself.

## Documentation workflow (required)

At the end of **every** successful phase, automatically update both `CLAUDE.md` (technical decisions, architecture state, constraints) and `README.md` (tech stack, working features, run instructions). This is a standing user requirement, not a per-request ask.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
# tailTUI
*A brutalist, keyboard-centric terminal user interface for Tailscale.*

[![ci](https://github.com/Phundahl/tailtui/actions/workflows/ci.yml/badge.svg)](https://github.com/Phundahl/tailtui/actions/workflows/ci.yml)

`tailTUI` is a fast, dense, single-screen control panel for your tailnet. It
wraps the `tailscale` CLI in a sharp, no-nonsense TUI built on the
[Charmbracelet](https://charm.sh) stack — so you can see your whole network,
Expand All @@ -27,6 +29,28 @@ for the opposite workflow:
drop you to the shell only when *they* need to (to paste an auth URL), then
restore the UI automatically.

## What's New in v1.2.0

- **Omarchy 4 ("Quattro") theme support.** Omarchy 4 moved the current-theme
store to `~/.local/state/omarchy/` *and* replaced the flat `color0`–`color15`
palette with semantic slots. tailTUI now probes both locations and reads both
schemas — detected by the keys present, not the filename — so upgraded and
older installs alike keep tracking the desktop theme instead of silently
falling back to the built-in palette.
- **Light theme support.** The theme's `mode` key is honored, shading panels and
modals in the right direction, so light palettes like `catppuccin-latte`,
`flexoki-light`, and `white` render as a light UI rather than an inverted one.
- **Demo / screenshot mode.** `TAILTUI_MOCK=1` runs the whole UI against an
in-memory fictional tailnet — every pane, modal, and animation, with no
daemon, no network, and no risk of touching a real configuration.
- **Account management is unprivileged-aware.** The four profile actions
(`a`/`Enter`/`d`/`l`) elevate properly for the root-owned Linux profile store,
and a session without access shows a clear "profile store locked" hint inside
the modal instead of quietly repeating errors into the log.
- **Prebuilt binaries.** Releases are built and published with goreleaser as
`.tar.gz`, `.deb`, and `.rpm` for amd64 and arm64, and the version shown in
the footer is stamped from the release tag.

## What's New in v1.1.0

tailTUI grew from a read-only dashboard into a full configuration tool:
Expand Down
52 changes: 52 additions & 0 deletions internal/tui/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package tui

import (
"strings"
"testing"
)

// restoreVersion snapshots appVersion and puts it back after the test, so the
// package-level override can't leak into the other suites.
func restoreVersion(t *testing.T) {
t.Helper()
orig := appVersion
t.Cleanup(func() { appVersion = orig })
}

// goreleaser's {{.Version}} expands to a bare "1.2.0" (no v prefix), so
// SetVersion normalizes it; an unstamped build passes "" and must keep the
// dev-build literal rather than blanking the footer.
func TestSetVersion(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"bare goreleaser version", "1.2.0", "v1.2.0"},
{"already prefixed", "v1.2.0", "v1.2.0"},
{"prerelease", "1.3.0-rc1", "v1.3.0-rc1"},
{"unstamped build keeps the literal", "", "v1.2.0"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
restoreVersion(t)
appVersion = "v1.2.0" // the dev-build literal
SetVersion(c.in)
if appVersion != c.want {
t.Errorf("SetVersion(%q) → %q, want %q", c.in, appVersion, c.want)
}
})
}
}

// The stamped version has to actually reach the footer — the whole point of
// the ldflag, which previously targeted a main.version var that didn't exist.
func TestVersionRendersInFooter(t *testing.T) {
restoreVersion(t)
SetVersion("9.9.9")

m := newReadyModel(t, 120, 40)
view := m.View()
if !strings.Contains(view, "v9.9.9") {
t.Error("stamped version does not appear in the rendered footer")
}
assertFlush(t, view, 120, 40)
}
24 changes: 20 additions & 4 deletions internal/tui/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,26 @@ import (
)

// Branding shown in the UI chrome.
const (
appName = "tailTUI"
appVersion = "v1.1.0"
)
const appName = "tailTUI"

// appVersion is the version string shown in the footer. It is a var, not a
// const, so a release build can override it from the git tag via SetVersion
// (see main.go / .goreleaser.yaml); the literal here is the dev-build value.
var appVersion = "v1.2.0"

// SetVersion overrides the footer version string, for builds that stamp the
// real tag in at link time. An empty version is ignored (keeping the dev-build
// literal), and a bare "1.2.0" is normalized to "v1.2.0" since goreleaser's
// {{.Version}} drops the prefix.
func SetVersion(v string) {
if v == "" {
return
}
if !strings.HasPrefix(v, "v") {
v = "v" + v
}
appVersion = v
}

// Fixed layout constants.
const (
Expand Down
7 changes: 7 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ import (
"github.com/Phundahl/tailtui/internal/tui"
)

// version is stamped in at link time by goreleaser
// (-X main.version={{.Version}}). It stays empty for a plain `go build`/`go
// run`, in which case the TUI keeps its own dev-build version literal.
var version string

func main() {
tui.SetVersion(version)

// Load the theme (native Omarchy palette if present, else the built-in
// "Matrix Core" default) and apply it before building any styles.
styles.Apply(styles.LoadTheme())
Expand Down
Loading