4 Commits

Author SHA1 Message Date
Niklas Ye 4a579bdbc6 Colour themes, defaulting to gruvbox dark
CI / test (pull_request) Successful in 4s
Every colour was a 256-colour ANSI index hardcoded in styles.go, so changing
the palette meant editing the styles themselves. This puts a semantic token set
between the two: styles name roles, a theme supplies the colours.

internal/theme holds the twelve tokens, the two built-ins (gruvbox-dark, the
new default, and gruvbox-light) and the loader for user themes in
~/.config/terdut-tui/themes/. A user file may 'extends:' a built-in and
override only what it cares about, and may shadow a built-in name to tweak it
in place. Unknown keys, malformed colours and incomplete themes are refused
with a message naming what went wrong.

Colours are truecolor hex now: lipgloss downsamples for 256- and 16-colour
terminals and honours NO_COLOR, so themes carry no fallbacks of their own.
An ANSI index is still accepted for anyone who would rather follow their
terminal's own palette.

The 21 package-level style vars become a Styles struct on the Model, which is
what rule 3 asked for all along; the four free functions in view.go take one as
their first argument. The embedded bubbles components are restyled from the
same tokens — otherwise a theme would leave a pink selected row and grey help
text behind. Note that the table's Cell style deliberately keeps no foreground:
bubbles renders cells before wrapping the row in Selected, so a colour there
cuts the selection highlight short.
2026-08-20 11:06:36 +02:00
Niklas Ye dc53d49c3e ci: fail on code that is not gofmt'd
CI / test (push) Successful in 2s
go vet says nothing about import order, so when the move to git.ryuvia.com
rewrote every import path without re-sorting them -- the new path sorts before
github.com/..., where the old one sorted after -- both repos went through a
green CI run and a release unformatted.

Added to the release workflow as well as CI, so the two keep running the same
checks; ci.yaml's header claims exactly that, and a check in one but not the
other would quietly make it false.

The step handles gofmt's two failure modes separately because they do not look
alike: a misformatted file is listed on stdout with exit 0, so the failure has
to be raised by hand, while a file that does not parse prints nothing to stdout
and exits 2 -- which a plain emptiness test reads as success. Verified against
all three cases (clean, misformatted, unparseable) before committing.
2026-08-19 21:29:36 +02:00
Niklas Ye d6c0f7508c Stop a table cursor from getting stuck at -1
CI / test (push) Successful in 4s
Release / test (push) Successful in 2s
Release / binaries (push) Successful in 9s
Assigning an on-call week panicked with "index out of range [-1]" on an
ordinary schedule. The index came from scheduleTable.Cursor().

The cursor is not ours. bubbles' SetRows clamps it down when rows shrink
(`if m.cursor > len(rows)-1`) but never back up, so setting zero rows drives it
to -1 and filling the table afterwards leaves it there -- -1 is not greater
than len-1, so nothing corrects it. Every table in this package is rebuilt from
empty exactly once, when the first WindowSizeMsg arrives before any fetch has
returned, so every cursor started at -1 and stayed there until the user pressed
up or down. Pressing a direction key first is why this was survivable at all.

setRows restores the invariant the rest of the package already assumes: a table
with rows has a usable cursor. Every rebuild goes through it.

Five call sites also bounds-checked only the top of the range, and are now
consistent with their siblings, which already had `i < 0 ||`. They were the
same latent panic: deleting a schedule entry, deleting a user, editing a topic,
opening the API key menu, and the week assignment that actually fired.

The regression test deliberately never calls SetCursor. That is what the
existing schedule tests do, and SetCursor clamps, which is exactly how this got
past them. It drives the real order instead: size, then data, then keys.

Also carries a gofmt pass, which is why untouched files appear in the diff.
The move to git.ryuvia.com rewrote import paths without re-sorting them, and
the new path sorts before github.com/charmbracelet/..., where the old one
sorted after. go vet does not look at import order, so CI had nothing to say.
2026-08-19 21:23:13 +02:00
Niklas Ye 6fdb4bbbf8 Move to Gitea: git.ryuvia.com/niklas/terdut-tui
CI / test (push) Successful in 13s
Release / test (push) Successful in 14s
Release / binaries (push) Successful in 1m37s
The module path, the CI pipeline and the self-updater all named GitHub. They now
name the Gitea instance everything else already runs on.

The workflows are rewritten rather than translated, for the reason recorded in
ci.yaml: Gitea's runner image is ubuntu:22.04, whose nodejs is Node 12, so no JS
action runs there -- actions/checkout@v4 dies with a SyntaxError before doing
anything. Every step is shell and checkout is a plain clone, which this public
repo needs no credential for. upload-artifact/download-artifact are JS actions
too, and there is no artifact store here, so the job that builds the binaries is
the job that publishes them.

internal/updater keeps its release and asset types unchanged: Gitea's release
payload carries the same tag_name, and its attachments the same name and
browser_download_url, so only the URL, the Accept header and one error string
move. The asset naming in release.yaml is load-bearing for that matching.

This does strand already-installed binaries, which still poll api.github.com.
The GitHub repository is left in place and untouched, so they report themselves
up to date rather than erroring; its last release is the bridge, and crossing it
is a one-time manual download.
2026-08-19 20:41:05 +02:00
22 changed files with 1326 additions and 361 deletions
+89
View File
@@ -0,0 +1,89 @@
name: CI
# The release workflow gates a tag, which is late: a broken commit sits green until
# somebody decides to publish. This runs the same checks on the way in.
#
# push is scoped to main so that a branch pushed as part of a pull request is not checked
# twice.
#
# No actions/checkout, deliberately -- same as the terdut-server, letsvisit and charts
# workflows. The runner image is ubuntu:22.04 whose `nodejs` package is Node 12, and
# actions/checkout@v4 is built with ES2022 static initialiser blocks, so it dies with
# `SyntaxError: Unexpected token '{'` before running. Cloning with git directly avoids JS
# actions entirely. This repo is public, so the clone needs no credential at all.
#
# `${{ }}` values are passed through `env:` and referenced as quoted shell variables: a
# ref name is attacker-influenced by anyone who can push a branch or open a PR, and
# expanding one straight into `run:` is a shell-injection vector.
on:
push:
branches: [main]
pull_request:
# A rapid series of pushes only needs the last one checked.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
REPO_URL: https://git.ryuvia.com/niklas/terdut-tui.git
jobs:
test:
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
# act_runner destroys a job's own volumes when it finishes, so without these every
# run re-downloads the whole module graph. The names must appear in the runner's
# container.valid_volumes allowlist (charts/act-runner in the k8s repo); unlisted
# volumes are dropped silently, so a workflow that looks correct can still be
# running uncached.
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -n "$HEAD_SHA" ]; then
# A pull_request ref_name is "<n>/merge", which is not a fetchable branch.
git clone "$REPO_URL" .
git checkout -q "$HEAD_SHA"
else
git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
fi
# This exists because `go vet` does not look at import order: the move to
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
# before github.com/..., and both repos sat unformatted through a green CI run and
# a release before anyone noticed.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
# draft of this step had exactly that hole.
- name: Format
run: |
if ! unformatted=$(gofmt -l .); then
echo "::error::gofmt could not parse the tree"
gofmt -l . # re-run unredirected so the parse errors reach the log
exit 1
fi
if [ -n "$unformatted" ]; then
echo "::error::not gofmt'd:"
echo "$unformatted"
gofmt -d .
exit 1
fi
- name: Vet
run: go vet ./...
# Covers the API client against a stub server, the Update state machine, and View
# rendering -- all three are pure enough to test without a terminal.
- name: Test
run: go test ./...
+142
View File
@@ -0,0 +1,142 @@
name: Release
# Checkout, interpolation and caching conventions match ci.yaml -- see the header there
# for why there are no JS actions and why every `${{ }}` goes through `env:`.
#
# There is no upload-artifact/download-artifact equivalent here (both are JS actions, and
# this Gitea has no artifact store wired up), so the job that builds the binaries is also
# the job that publishes them. Nothing is handed between jobs.
#
# The asset names matter beyond being tidy: internal/updater looks for exactly
# terdut-tui-<tag>-<goos>-<goarch> in the latest release and reports every available name
# when it cannot find one. Renaming the pattern here breaks self-update for every
# installed binary.
on:
push:
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
env:
REPO_URL: https://git.ryuvia.com/niklas/terdut-tui.git
API: https://git.ryuvia.com/api/v1/repos/niklas/terdut-tui
jobs:
# Gates the build, so a tag that fails here publishes no binaries.
test:
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
# This exists because `go vet` does not look at import order: the move to
# git.ryuvia.com rewrote every import path without re-sorting, the new path sorts
# before github.com/..., and both repos sat unformatted through a green CI run and
# a release before anyone noticed.
#
# Both of gofmt's failure modes need handling, and they are not alike. A file that
# is merely misformatted is listed on stdout with exit 0 -- so the failure has to
# be raised by hand. A file that does not parse is the opposite: nothing on stdout
# and exit 2, which a naive `[ -n "$unformatted" ]` reads as success. The first
# draft of this step had exactly that hole.
- name: Format
run: |
if ! unformatted=$(gofmt -l .); then
echo "::error::gofmt could not parse the tree"
gofmt -l . # re-run unredirected so the parse errors reach the log
exit 1
fi
if [ -n "$unformatted" ]; then
echo "::error::not gofmt'd:"
echo "$unformatted"
gofmt -d .
exit 1
fi
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
binaries:
needs: test
runs-on: ubuntu-latest
container:
image: golang:1.26.6-bookworm
volumes:
- go-mod-cache:/go/pkg/mod
- go-build-cache:/root/.cache/go-build
- gobin-cache:/go/bin
steps:
- name: Checkout
env:
REF_NAME: ${{ github.ref_name }}
run: git clone --depth=1 --branch "$REF_NAME" "$REPO_URL" .
- name: Build every target
env:
REF_NAME: ${{ github.ref_name }}
run: |
set -eu
mkdir -p dist
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
GOOS="${target%/*}"
GOARCH="${target#*/}"
out="dist/terdut-tui-${REF_NAME}-${GOOS}-${GOARCH}"
echo "building $out"
GOOS="$GOOS" GOARCH="$GOARCH" go build \
-ldflags "-X main.version=${REF_NAME}" \
-o "$out" .
done
# Creating the release is made idempotent rather than assumed-new: a re-run of a
# failed release must not die on the release that already exists. Assets are
# replaced the same way, so a re-run repairs a partial upload.
- name: Publish the release
env:
REF_NAME: ${{ github.ref_name }}
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
auth="Authorization: token $TOKEN"
body=$(curl -sf -H "$auth" "$API/releases/tags/$REF_NAME" || true)
if [ -z "$body" ]; then
body=$(curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$REF_NAME\",\"name\":\"$REF_NAME\"}" \
"$API/releases")
fi
# The release object serialises `id` first, so the first match is the release's
# own id and not one of the nested author/asset ids.
release_id=$(printf '%s' "$body" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
[ -n "$release_id" ] || { echo "::error::could not determine release id"; exit 1; }
echo "release id $release_id"
for f in dist/*; do
name=$(basename "$f")
# Drop an existing asset of the same name first: Gitea happily stores two
# attachments with one name, and the updater matches by name.
old=$(curl -sf -H "$auth" "$API/releases/$release_id/assets" \
| tr '}' '\n' | grep "\"name\":\"$name\"" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -n "$old" ]; then
curl -sf -X DELETE -H "$auth" "$API/releases/$release_id/assets/$old" || true
fi
echo "uploading $name"
curl -sf -X POST -H "$auth" -F "attachment=@$f" \
"$API/releases/$release_id/assets?name=$name" > /dev/null
done
-32
View File
@@ -1,32 +0,0 @@
name: CI
# The release workflow gates a tag, which is late: a broken commit sits green
# until somebody decides to publish. This runs the same checks on the way in.
#
# push is scoped to main so that a branch pushed as part of a pull request is
# not checked twice.
on:
push:
branches: [main]
pull_request:
# A rapid series of pushes only needs the last one checked.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
-76
View File
@@ -1,76 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
# Gates the build, so a tag that fails here publishes no binaries. The suite
# covers the API client against a stub server, the Update state machine, and
# View rendering — all three are pure enough to test without a terminal.
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Vet
run: go vet ./...
- name: Test
run: go test ./...
build:
needs: test
runs-on: ubuntu-latest
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: |
go build \
-ldflags "-X main.version=${{ github.ref_name }}" \
-o terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }} \
.
- uses: actions/upload-artifact@v4
with:
name: terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
path: terdut-tui-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: 'terdut-tui-*'
+11 -2
View File
@@ -1,6 +1,6 @@
# terdut-tui # terdut-tui
TUI client for [terdut-server](https://github.com/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.4.0+**. TUI client for [terdut-server](https://git.ryuvia.com/niklas/terdut-server), a Prometheus Alertmanager receiver and incident manager. Requires server **v0.4.0+**.
## Domain model ## Domain model
@@ -28,12 +28,13 @@ non-destructive "not now" alternative.
main.go CLI entry point: flags, config load, health check, start TUI main.go CLI entry point: flags, config load, health check, start TUI
internal/api/client.go REST API client — one method per endpoint internal/api/client.go REST API client — one method per endpoint
internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml) internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml)
internal/theme/ Colour themes: semantic tokens, built-ins, user file loader
internal/tui/ Bubbletea UI internal/tui/ Bubbletea UI
model.go Model struct, mode/section constants, Init(), tea.Cmd constructors model.go Model struct, mode/section constants, Init(), tea.Cmd constructors
update.go Update() — dispatch only, no API calls inline update.go Update() — dispatch only, no API calls inline
view.go View() — pure rendering view.go View() — pure rendering
keys.go keyMap (bubbles/key pattern) keys.go keyMap (bubbles/key pattern)
styles.go All lipgloss styles styles.go Styles struct — every lipgloss style, built from a theme
internal/updater/updater.go Self-update via GitHub Releases internal/updater/updater.go Self-update via GitHub Releases
``` ```
@@ -43,6 +44,9 @@ internal/updater/updater.go Self-update via GitHub Releases
2. **`View()` is pure** — no side effects, no state mutations. 2. **`View()` is pure** — no side effects, no state mutations.
3. **All state in `Model`** — no globals. 3. **All state in `Model`** — no globals.
4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`. 4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`.
Styles live on `Model.styles`, built once by `newStyles(theme.Theme)`; the
handful of free functions in `view.go` take a `Styles` as their first
argument. No colour literal appears outside `internal/theme`.
## Config ## Config
@@ -52,8 +56,13 @@ Location: `~/.config/terdut-tui/config.yaml`
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <64-char hex key> api_key: <64-char hex key>
refresh_interval: 30 # seconds, optional, default 30 refresh_interval: 30 # seconds, optional, default 30
theme: gruvbox-dark # optional, default gruvbox-dark
``` ```
Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML
files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in.
See the README for the token list.
The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`). The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`).
## Running ## Running
+42 -3
View File
@@ -1,6 +1,6 @@
# terdut-tui # terdut-tui
A terminal user interface for [terdut-server](https://github.com/terdut-server). Communicates with the server over its REST API. A terminal user interface for [terdut-server](https://git.ryuvia.com/niklas/terdut-server). Communicates with the server over its REST API.
Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea). Written in Go using [Bubbletea](https://github.com/charmbracelet/bubbletea).
@@ -55,10 +55,10 @@ editing one reports the server's 404.
## Installation ## Installation
Download the latest release binary for your platform from the [releases page](https://github.com/yeniklas/terdut-tui/releases), or build from source: Download the latest release binary for your platform from the [releases page](https://git.ryuvia.com/niklas/terdut-tui/releases), or build from source:
```bash ```bash
go install github.com/yeniklas/terdut-tui@latest go install git.ryuvia.com/niklas/terdut-tui@latest
``` ```
## Configuration ## Configuration
@@ -69,10 +69,49 @@ Create `~/.config/terdut-tui/config.yaml`:
server_url: https://terdut.example.com server_url: https://terdut.example.com
api_key: <your-api-key> api_key: <your-api-key>
refresh_interval: 30 # seconds, optional refresh_interval: 30 # seconds, optional
theme: gruvbox-dark # optional, this is the default
``` ```
The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key. The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key.
## Themes
Two themes ship with the client: `gruvbox-dark` (the default) and
`gruvbox-light`. Both colour foregrounds only — the terminal supplies the
background — so pick the one that matches the background you already run.
To make your own, drop a file in `~/.config/terdut-tui/themes/` and name it in
`theme:`. `extends` inherits a built-in, so a file only has to list what it
changes:
```yaml
# ~/.config/terdut-tui/themes/mine.yaml
extends: gruvbox-dark
primary: "#d3869b"
accent: "#fabd2f"
```
A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak
the default without renaming it.
Without `extends`, every token must be set. The twelve are:
| Token | Where it shows |
|---|---|
| `primary` | header, active tab, selected row, cursors |
| `on_primary` | text drawn *on* `primary` — the active tab and selected row |
| `text` | incident titles and other emphasis |
| `muted` | secondary text, dividers, footer, table headers |
| `accent` | status line, acknowledged incidents, the by-day chart |
| `firing` | firing alerts, triggered incidents, the by-hour chart |
| `resolved` | resolved alerts and incidents, the top-alerts chart |
| `error` | error banners |
| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label |
Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you
would rather follow your terminal's own colours. Colours are downsampled
automatically on 256- and 16-colour terminals, and `NO_COLOR` is honoured.
## Usage ## Usage
``` ```
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/yeniklas/terdut-tui module git.ryuvia.com/niklas/terdut-tui
go 1.25.9 go 1.25.9
+4 -1
View File
@@ -15,12 +15,14 @@ type Config struct {
ServerURL string ServerURL string
APIKey string APIKey string
RefreshInterval time.Duration RefreshInterval time.Duration
Theme string
} }
type rawConfig struct { type rawConfig struct {
ServerURL string `yaml:"server_url"` ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"` APIKey string `yaml:"api_key"`
RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds
Theme string `yaml:"theme,omitempty"`
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@@ -33,7 +35,7 @@ func Load() (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>", path) return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: <your-api-key>\n theme: gruvbox-dark # optional", path)
} }
return nil, fmt.Errorf("cannot read config file: %w", err) return nil, fmt.Errorf("cannot read config file: %w", err)
} }
@@ -59,5 +61,6 @@ func Load() (*Config, error) {
ServerURL: raw.ServerURL, ServerURL: raw.ServerURL,
APIKey: raw.APIKey, APIKey: raw.APIKey,
RefreshInterval: interval, RefreshInterval: interval,
Theme: raw.Theme,
}, nil }, nil
} }
+88
View File
@@ -0,0 +1,88 @@
package theme
import "sort"
// The gruvbox palettes, in the author's original names. Dark uses the bright
// variants and light the faded ones, which is what keeps each readable against
// its own background.
const (
darkBg0 = "#282828"
darkFg1 = "#ebdbb2"
darkGray = "#928374"
darkRed = "#fb4934"
darkGrn = "#b8bb26"
darkYel = "#fabd2f"
darkBlu = "#83a598"
darkAqua = "#8ec07c"
darkOrng = "#fe8019"
lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836"
lightFg4 = "#7c6f64"
lightRed = "#9d0006"
lightGrn = "#79740e"
lightYel = "#b57614"
lightBlu = "#076678"
lightAqua = "#427b58"
lightOrng = "#af3a03"
)
// GruvboxDark is the default scheme. It assumes a dark terminal background:
// themes colour foregrounds only, so the terminal supplies the canvas.
var GruvboxDark = Theme{
Name: "gruvbox-dark",
Primary: darkBlu,
OnPrimary: darkBg0,
Text: darkFg1,
Muted: darkGray,
Accent: darkOrng,
Firing: darkRed,
Resolved: darkGrn,
Error: darkRed,
SevCritical: darkRed,
SevError: darkOrng,
SevWarning: darkYel,
SevInfo: darkAqua,
}
// GruvboxLight is the same scheme against a light terminal background.
var GruvboxLight = Theme{
Name: "gruvbox-light",
Primary: lightBlu,
OnPrimary: lightBg0,
Text: lightFg1,
Muted: lightFg4,
Accent: lightOrng,
Firing: lightRed,
Resolved: lightGrn,
Error: lightRed,
SevCritical: lightRed,
SevError: lightOrng,
SevWarning: lightYel,
SevInfo: lightAqua,
}
// Default is the theme used when the config names none.
var Default = GruvboxDark
var builtins = map[string]Theme{
GruvboxDark.Name: GruvboxDark,
GruvboxLight.Name: GruvboxLight,
}
// BuiltinNames lists the compiled-in themes, sorted, for error messages and
// documentation.
func BuiltinNames() []string {
names := make([]string, 0, len(builtins))
for name := range builtins {
names = append(names, name)
}
sort.Strings(names)
return names
}
+196
View File
@@ -0,0 +1,196 @@
package theme
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"gopkg.in/yaml.v3"
)
// rawTheme is the on-disk form. Every token is a pointer so an absent key is
// distinguishable from an empty one, which is what lets 'extends' overwrite
// only what the file actually mentions.
type rawTheme struct {
Extends *string `yaml:"extends"`
Primary *string `yaml:"primary"`
OnPrimary *string `yaml:"on_primary"`
Text *string `yaml:"text"`
Muted *string `yaml:"muted"`
Accent *string `yaml:"accent"`
Firing *string `yaml:"firing"`
Resolved *string `yaml:"resolved"`
Error *string `yaml:"error"`
SevCritical *string `yaml:"sev_critical"`
SevError *string `yaml:"sev_error"`
SevWarning *string `yaml:"sev_warning"`
SevInfo *string `yaml:"sev_info"`
}
// binding ties a YAML key to its raw value and the field it fills, so parsing,
// merging and the missing-token report all walk the same list.
type binding struct {
key string
src *string
dst *lipgloss.Color
}
func bindings(r *rawTheme, t *Theme) []binding {
return []binding{
{"primary", r.Primary, &t.Primary},
{"on_primary", r.OnPrimary, &t.OnPrimary},
{"text", r.Text, &t.Text},
{"muted", r.Muted, &t.Muted},
{"accent", r.Accent, &t.Accent},
{"firing", r.Firing, &t.Firing},
{"resolved", r.Resolved, &t.Resolved},
{"error", r.Error, &t.Error},
{"sev_critical", r.SevCritical, &t.SevCritical},
{"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning},
{"sev_info", r.SevInfo, &t.SevInfo},
}
}
// tokenKeys lists the colour keys a theme file may set, in the order they are
// documented.
func tokenKeys() []string {
bs := bindings(&rawTheme{}, &Theme{})
keys := make([]string, len(bs))
for i, b := range bs {
keys[i] = b.key
}
return keys
}
func isTokenKey(k string) bool {
if k == "extends" {
return true
}
for _, want := range tokenKeys() {
if k == want {
return true
}
}
return false
}
// Load resolves a theme by name. An empty name is the default; otherwise a file
// in the user's themes directory wins over a built-in of the same name, so the
// documented way to tweak a built-in is to shadow it rather than rename it.
func Load(name string) (Theme, error) {
if name == "" {
return Default, nil
}
dir, err := os.UserConfigDir()
if err != nil {
// Built-ins do not need the disk, so a missing config directory only
// matters for user themes.
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("cannot determine config directory: %w", err)
}
return loadFrom(filepath.Join(dir, "terdut-tui", "themes"), name)
}
func loadFrom(dir, name string) (Theme, error) {
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return Theme{}, fmt.Errorf("invalid theme name %q: a theme is a bare name, not a path", name)
}
path := filepath.Join(dir, name+".yaml")
data, err := os.ReadFile(path)
switch {
case err == nil:
return parse(name, data)
case !os.IsNotExist(err):
return Theme{}, fmt.Errorf("cannot read theme file %s: %w", path, err)
}
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("unknown theme %q\n\nBuilt-in themes: %s\nOr define your own at %s",
name, strings.Join(BuiltinNames(), ", "), path)
}
func parse(name string, data []byte) (Theme, error) {
// Check the keys before decoding, so a typo'd token reports itself by name
// alongside the ones that would have worked rather than surfacing yaml's
// message about an internal Go type.
var keys map[string]yaml.Node
if err := yaml.Unmarshal(data, &keys); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
for k := range keys {
if !isTokenKey(k) {
return Theme{}, fmt.Errorf("theme %q: unknown key %q\n\nValid keys: extends, %s",
name, k, strings.Join(tokenKeys(), ", "))
}
}
var raw rawTheme
if err := yaml.Unmarshal(data, &raw); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
t := Theme{Name: name}
if raw.Extends != nil {
base, ok := builtins[*raw.Extends]
if !ok {
return Theme{}, fmt.Errorf("theme %q: 'extends' names unknown theme %q (built-ins: %s)",
name, *raw.Extends, strings.Join(BuiltinNames(), ", "))
}
t = base
t.Name = name
}
var missing []string
for _, b := range bindings(&raw, &t) {
if b.src == nil {
if raw.Extends == nil {
missing = append(missing, b.key)
}
continue
}
c, err := parseColor(*b.src)
if err != nil {
return Theme{}, fmt.Errorf("theme %q: %s: %w", name, b.key, err)
}
*b.dst = c
}
if len(missing) > 0 {
return Theme{}, fmt.Errorf("theme %q is missing %s\n\nEither set every token or add 'extends: %s' to inherit the rest",
name, strings.Join(missing, ", "), Default.Name)
}
return t, nil
}
var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// parseColor accepts what lipgloss can actually render: a hex value, or an ANSI
// palette index for people who would rather follow their terminal's colours.
func parseColor(s string) (lipgloss.Color, error) {
if hexColor.MatchString(s) {
return lipgloss.Color(s), nil
}
// strconv.Itoa round-trips to reject "+7" and "007", which lipgloss would
// pass to the terminal verbatim.
if n, err := strconv.Atoi(s); err == nil && n >= 0 && n <= 255 && strconv.Itoa(n) == s {
return lipgloss.Color(s), nil
}
return "", fmt.Errorf("invalid colour %q, want a hex value like \"#83a598\" or an ANSI index 0-255", s)
}
+180
View File
@@ -0,0 +1,180 @@
package theme
import (
"os"
"path/filepath"
"strings"
"testing"
)
// write drops a theme file into dir and returns the directory, so each test
// works against its own themes directory rather than the user's.
func write(t *testing.T, dir, name, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name+".yaml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_EmptyNameIsTheDefault(t *testing.T) {
got, err := Load("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Name != Default.Name {
t.Errorf("got theme %q, want %q", got.Name, Default.Name)
}
}
func TestLoadFrom_BuiltinsResolveWithoutAFile(t *testing.T) {
dir := t.TempDir()
for _, name := range BuiltinNames() {
got, err := loadFrom(dir, name)
if err != nil {
t.Fatalf("%s: unexpected error: %v", name, err)
}
if got.Name != name {
t.Errorf("got theme %q, want %q", got.Name, name)
}
if got.Primary == "" {
t.Errorf("%s: primary is unset", name)
}
}
}
func TestLoadFrom_ExtendsOverridesOnlyWhatIsNamed(t *testing.T) {
dir := t.TempDir()
write(t, dir, "mine", "extends: gruvbox-dark\nprimary: \"#d3869b\"\n")
got, err := loadFrom(dir, "mine")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Name != "mine" {
t.Errorf("got name %q, want %q", got.Name, "mine")
}
if got.Primary != "#d3869b" {
t.Errorf("got primary %q, want the override", got.Primary)
}
if got.Muted != GruvboxDark.Muted {
t.Errorf("got muted %q, want inherited %q", got.Muted, GruvboxDark.Muted)
}
if got.SevInfo != GruvboxDark.SevInfo {
t.Errorf("got sev_info %q, want inherited %q", got.SevInfo, GruvboxDark.SevInfo)
}
}
func TestLoadFrom_UserFileShadowsABuiltin(t *testing.T) {
dir := t.TempDir()
write(t, dir, "gruvbox-dark", "extends: gruvbox-dark\naccent: \"#fabd2f\"\n")
got, err := loadFrom(dir, "gruvbox-dark")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Accent != "#fabd2f" {
t.Errorf("got accent %q, want the shadowing file's value", got.Accent)
}
}
func TestLoadFrom_WithoutExtendsEveryTokenIsRequired(t *testing.T) {
dir := t.TempDir()
write(t, dir, "partial", "primary: \"#83a598\"\n")
_, err := loadFrom(dir, "partial")
if err == nil {
t.Fatal("expected an error for a theme missing tokens")
}
for _, want := range []string{"muted", "sev_info", "extends"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should mention %q, got: %v", want, err)
}
}
}
func TestLoadFrom_CompleteThemeNeedsNoExtends(t *testing.T) {
dir := t.TempDir()
write(t, dir, "full", `primary: "#000001"
on_primary: "#000002"
text: "#000003"
muted: "#000004"
accent: "#000005"
firing: "#000006"
resolved: "#000007"
error: "#000008"
sev_critical: "#000009"
sev_error: "#00000a"
sev_warning: "#00000b"
sev_info: "#00000c"
`)
got, err := loadFrom(dir, "full")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
t.Errorf("tokens not applied: %+v", got)
}
}
func TestLoadFrom_Errors(t *testing.T) {
tests := []struct {
name string
file string // empty means: write no file at all
body string
want string
}{
{"unknown name", "", "", "unknown theme"},
{"unknown key", "typo", "extends: gruvbox-dark\nprimry: \"#83a598\"\n", `unknown key "primry"`},
{"unknown key lists the valid ones", "typo2", "primry: \"#83a598\"\n", "Valid keys: extends, primary,"},
{"bad colour", "bad", "extends: gruvbox-dark\nprimary: notacolour\n", "invalid colour"},
{"bad colour names the token", "badkey", "extends: gruvbox-dark\nsev_warning: \"#gggggg\"\n", "sev_warning"},
{"unknown base", "orphan", "extends: solarized\nprimary: \"#83a598\"\n", "unknown theme \"solarized\""},
{"malformed yaml", "broken", "extends: [\n", "invalid theme"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
name := tt.file
if name == "" {
name = "missing"
} else {
write(t, dir, name, tt.body)
}
_, err := loadFrom(dir, name)
if err == nil {
t.Fatal("expected an error")
}
if !strings.Contains(err.Error(), tt.want) {
t.Errorf("error should mention %q, got: %v", tt.want, err)
}
})
}
}
func TestLoadFrom_RejectsPathsAsNames(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"../secrets", "sub/theme", ".."} {
if _, err := loadFrom(dir, name); err == nil {
t.Errorf("%q: expected an error", name)
}
}
}
func TestParseColor(t *testing.T) {
ok := []string{"#83a598", "#FFF", "#abc", "0", "15", "255"}
for _, s := range ok {
if _, err := parseColor(s); err != nil {
t.Errorf("parseColor(%q) = %v, want no error", s, err)
}
}
bad := []string{"", "83a598", "#ab", "#abcd", "#gggggg", "256", "-1", "+7", "007", "red"}
for _, s := range bad {
if _, err := parseColor(s); err == nil {
t.Errorf("parseColor(%q) = nil, want an error", s)
}
}
}
+32
View File
@@ -0,0 +1,32 @@
// Package theme resolves the named colour scheme the TUI renders with. A theme
// is a flat set of semantic tokens — roles like "muted" or "firing", never hues
// — so a new scheme is a table of colours rather than a change to the views.
package theme
import "github.com/charmbracelet/lipgloss"
// Theme is the palette the UI draws from. Every token is a foreground except
// OnPrimary, which is the text colour for the two places that invert: the
// active tab and the selected table row.
//
// Colours are truecolor hex; lipgloss downsamples them for 256- and 16-colour
// terminals and drops them entirely under NO_COLOR, so themes do not carry
// fallbacks of their own.
type Theme struct {
Name string
Primary lipgloss.Color // header, tab highlight, selection
OnPrimary lipgloss.Color // text drawn on a Primary background
Text lipgloss.Color // default emphasis foreground
Muted lipgloss.Color // secondary text, dividers, borders
Accent lipgloss.Color // status line, acknowledged, by-day chart
Firing lipgloss.Color
Resolved lipgloss.Color
Error lipgloss.Color
SevCritical lipgloss.Color
SevError lipgloss.Color
SevWarning lipgloss.Color
SevInfo lipgloss.Color
}
+44 -24
View File
@@ -4,13 +4,13 @@ import (
"fmt" "fmt"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
) )
// ── Enums ────────────────────────────────────────────────────────────────── // ── Enums ──────────────────────────────────────────────────────────────────
@@ -244,12 +244,14 @@ type Model struct {
apiKeyRevokeInput textinput.Model apiKeyRevokeInput textinput.Model
revealedAPIKey api.APIKey revealedAPIKey api.APIKey
help help.Model help help.Model
keys keyMap keys keyMap
styles Styles
} }
func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model { func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration, th theme.Theme) Model {
ts := defaultTableStyles() st := newStyles(th)
ts := st.Table()
incidentT := table.New(table.WithFocused(true)) incidentT := table.New(table.WithFocused(true))
incidentT.SetStyles(ts) incidentT.SetStyles(ts)
@@ -301,6 +303,15 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
revokeIn.Placeholder = "integer key ID" revokeIn.Placeholder = "integer key ID"
revokeIn.CharLimit = 20 revokeIn.CharLimit = 20
for _, in := range []*textinput.Model{
&noteIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn,
} {
*in = st.Input(*in)
}
helpModel := help.New()
helpModel.Styles = st.Help()
now := time.Now().UTC() now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
weekday := int(today.Weekday()) weekday := int(today.Weekday())
@@ -333,8 +344,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
ntfyTopicInput: topicIn, ntfyTopicInput: topicIn,
apiKeyNameInput: keyNameIn, apiKeyNameInput: keyNameIn,
apiKeyRevokeInput: revokeIn, apiKeyRevokeInput: revokeIn,
help: help.New(), help: helpModel,
keys: keys, keys: keys,
styles: st,
} }
} }
@@ -344,37 +356,45 @@ func (m Model) Init() tea.Cmd {
// ── Table rebuilders ─────────────────────────────────────────────────────── // ── Table rebuilders ───────────────────────────────────────────────────────
func defaultTableStyles() table.Styles { // setRows replaces a table's rows and keeps its cursor in a state the rest of
s := table.DefaultStyles() // this package can rely on: valid whenever the table has any rows at all.
s.Header = s.Header.Bold(true) //
s.Selected = s.Selected. // bubbles does not do that on its own. SetRows only clamps the cursor *down*
Foreground(lipgloss.Color("0")). // (`if m.cursor > len(rows)-1`), so setting zero rows drives it to -1 and
Background(colorPrimary). // nothing ever brings it back — filling the table later leaves -1 in place,
Bold(true) // because -1 is not greater than len-1. Every table here is rebuilt from empty
return s // once at startup, when the first WindowSizeMsg arrives before any fetch has
// returned, so without this every cursor is -1 until the user happens to press
// up or down. Indexing a slice with that panics, which is exactly what
// assigning an on-call week did.
func setRows(t *table.Model, rows []table.Row) {
t.SetRows(rows)
if len(rows) > 0 && t.Cursor() < 0 {
t.SetCursor(0)
}
} }
func (m *Model) rebuildIncidentTable() { func (m *Model) rebuildIncidentTable() {
m.incidentTable.SetColumns(incidentColumns(m.width)) m.incidentTable.SetColumns(incidentColumns(m.width))
m.incidentTable.SetRows(incidentRows(m.incidents)) setRows(&m.incidentTable, incidentRows(m.incidents))
m.incidentTable.SetHeight(tableHeight(m.height, 8)) m.incidentTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildTable() { func (m *Model) rebuildTable() {
m.alertTable.SetColumns(alertColumns(m.width)) m.alertTable.SetColumns(alertColumns(m.width))
m.alertTable.SetRows(alertRows(m.alerts)) setRows(&m.alertTable, alertRows(m.alerts))
m.alertTable.SetHeight(tableHeight(m.height, 8)) m.alertTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildArchivedTable() { func (m *Model) rebuildArchivedTable() {
m.archivedTable.SetColumns(incidentColumns(m.width)) m.archivedTable.SetColumns(incidentColumns(m.width))
m.archivedTable.SetRows(incidentRows(m.archivedIncidents)) setRows(&m.archivedTable, incidentRows(m.archivedIncidents))
m.archivedTable.SetHeight(tableHeight(m.height, 8)) m.archivedTable.SetHeight(tableHeight(m.height, 8))
} }
func (m *Model) rebuildScheduleTable() { func (m *Model) rebuildScheduleTable() {
m.scheduleTable.SetColumns(scheduleColumns(m.width)) m.scheduleTable.SetColumns(scheduleColumns(m.width))
m.scheduleTable.SetRows(scheduleRows(m.scheduleDays)) setRows(&m.scheduleTable, scheduleRows(m.scheduleDays))
m.scheduleTable.SetHeight(tableHeight(m.height, 10)) m.scheduleTable.SetHeight(tableHeight(m.height, 10))
} }
@@ -384,7 +404,7 @@ func (m *Model) rebuildUserPickerTable() {
for i, u := range m.users { for i, u := range m.users {
rows[i] = table.Row{u.Username, u.Email} rows[i] = table.Row{u.Username, u.Email}
} }
m.userPickerTable.SetRows(rows) setRows(&m.userPickerTable, rows)
m.userPickerTable.SetHeight(tableHeight(m.height, 10)) m.userPickerTable.SetHeight(tableHeight(m.height, 10))
} }
@@ -398,7 +418,7 @@ func (m *Model) rebuildUserManageTable() {
} }
rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")} rows[i] = table.Row{u.Username, u.Email, topic, u.CreatedAt.UTC().Format("2006-01-02")}
} }
m.userManageTable.SetRows(rows) setRows(&m.userManageTable, rows)
m.userManageTable.SetHeight(tableHeight(m.height, 10)) m.userManageTable.SetHeight(tableHeight(m.height, 10))
} }
@@ -415,16 +435,16 @@ func (m *Model) refreshDetailContent() {
return return
} }
if m.mode == modeAlertDetail { if m.mode == modeAlertDetail {
m.detailViewport.SetContent(buildAlertDetailContent(m.selectedAlert, m.width)) m.detailViewport.SetContent(buildAlertDetailContent(m.styles, m.selectedAlert, m.width))
return return
} }
m.detailViewport.SetContent( m.detailViewport.SetContent(
buildIncidentDetailContent(m.selectedIncident, m.timeline, m.noteCursor, m.width)) buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.noteCursor, m.width))
} }
func (m *Model) refreshStatsContent() { func (m *Model) refreshStatsContent() {
m.statsViewport.SetContent( m.statsViewport.SetContent(
buildStatsContent(m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width)) buildStatsContent(m.styles, m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width))
} }
func (m Model) statsViewportHeight() int { func (m Model) statsViewportHeight() int {
+4 -3
View File
@@ -4,8 +4,9 @@ import (
"testing" "testing"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/table"
"github.com/yeniklas/terdut-tui/internal/api"
) )
func TestNextFilter(t *testing.T) { func TestNextFilter(t *testing.T) {
@@ -177,7 +178,7 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) {
func TestUserManageRows_ShowMissingTopic(t *testing.T) { func TestUserManageRows_ShowMissingTopic(t *testing.T) {
topic := "terdut-niklas" topic := "terdut-niklas"
empty := "" empty := ""
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.users = []api.User{ m.users = []api.User{
{ID: 1, Username: "niklas", NtfyTopic: &topic}, {ID: 1, Username: "niklas", NtfyTopic: &topic},
@@ -283,7 +284,7 @@ func TestBuildScheduleDays(t *testing.T) {
// scheduledWeek builds a model showing the week of 2026-07-27 with the given // scheduledWeek builds a model showing the week of 2026-07-27 with the given
// entries already on the rota. // entries already on the rota.
func scheduledWeek(entries []api.ScheduleEntry) Model { func scheduledWeek(entries []api.ScheduleEntry) Model {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.connected = true m.connected = true
m.activeSection = sectionSchedule m.activeSection = sectionSchedule
+143 -67
View File
@@ -3,97 +3,173 @@ package tui
import ( import (
"strings" "strings"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
) )
var ( // Styles is every style the views draw with, built once from a theme and held
colorPrimary = lipgloss.Color("69") // blue // on the Model. Nothing here reads a colour literal: the theme is the only
colorMuted = lipgloss.Color("240") // gray // place a colour is named.
colorFiring = lipgloss.Color("196") // red type Styles struct {
colorResolved = lipgloss.Color("70") // green Header lipgloss.Style
colorAccent = lipgloss.Color("214") // orange TabActive lipgloss.Style
TabInactive lipgloss.Style
Footer lipgloss.Style
Status lipgloss.Style
colorSevCritical = lipgloss.Color("196") // red Error lipgloss.Style
colorSevError = lipgloss.Color("202") // dark orange Firing lipgloss.Style
colorSevWarning = lipgloss.Color("214") // orange Resolved lipgloss.Style
colorSevInfo = lipgloss.Color("39") // cyan Muted lipgloss.Style
Accent lipgloss.Style
styleHeader = lipgloss.NewStyle(). AlertName lipgloss.Style
Bold(true). Bold lipgloss.Style
Foreground(colorPrimary). Selected lipgloss.Style
Padding(0, 1)
styleTabActive = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("0")).
Background(colorPrimary).
Padding(0, 2)
styleTabInactive = lipgloss.NewStyle().
Foreground(colorMuted).
Padding(0, 2)
styleFooter = lipgloss.NewStyle().
Foreground(colorMuted)
styleStatus = lipgloss.NewStyle().
Foreground(colorAccent).
Bold(true)
styleError = lipgloss.NewStyle().
Foreground(colorFiring).
Bold(true)
styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true)
styleResolved = lipgloss.NewStyle().Foreground(colorResolved)
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
styleAlertName = lipgloss.NewStyle().Bold(true)
styleBold = lipgloss.NewStyle().Bold(true)
styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true)
styleAccent = lipgloss.NewStyle().Foreground(colorAccent)
// Incident status. Triggered is unclaimed work and reads as loudly as a // Incident status. Triggered is unclaimed work and reads as loudly as a
// firing alert; acknowledged means somebody has it. // firing alert; acknowledged means somebody has it.
styleTriggered = lipgloss.NewStyle().Foreground(colorFiring).Bold(true) Triggered lipgloss.Style
styleAcknowledged = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) Acknowledged lipgloss.Style
styleSnoozed = lipgloss.NewStyle().Foreground(colorMuted).Italic(true) Snoozed lipgloss.Style
// Severity, over the conventional Alertmanager label values. // Severity, over the conventional Alertmanager label values.
styleSevCritical = lipgloss.NewStyle().Foreground(colorSevCritical).Bold(true) SevCritical lipgloss.Style
styleSevError = lipgloss.NewStyle().Foreground(colorSevError).Bold(true) SevError lipgloss.Style
styleSevWarning = lipgloss.NewStyle().Foreground(colorSevWarning) SevWarning lipgloss.Style
styleSevInfo = lipgloss.NewStyle().Foreground(colorSevInfo) SevInfo lipgloss.Style
)
// severityStyle picks the style for a severity label, falling back to muted for theme theme.Theme
}
func newStyles(t theme.Theme) Styles {
return Styles{
Header: lipgloss.NewStyle().
Bold(true).
Foreground(t.Primary).
Padding(0, 1),
TabActive: lipgloss.NewStyle().
Bold(true).
Foreground(t.OnPrimary).
Background(t.Primary).
Padding(0, 2),
TabInactive: lipgloss.NewStyle().
Foreground(t.Muted).
Padding(0, 2),
Footer: lipgloss.NewStyle().Foreground(t.Muted),
Status: lipgloss.NewStyle().
Foreground(t.Accent).
Bold(true),
Error: lipgloss.NewStyle().
Foreground(t.Error).
Bold(true),
Firing: lipgloss.NewStyle().Foreground(t.Firing).Bold(true),
Resolved: lipgloss.NewStyle().Foreground(t.Resolved),
Muted: lipgloss.NewStyle().Foreground(t.Muted),
Accent: lipgloss.NewStyle().Foreground(t.Accent),
AlertName: lipgloss.NewStyle().Foreground(t.Text).Bold(true),
Bold: lipgloss.NewStyle().Foreground(t.Text).Bold(true),
Selected: lipgloss.NewStyle().Foreground(t.Primary).Bold(true),
Triggered: lipgloss.NewStyle().Foreground(t.Firing).Bold(true),
Acknowledged: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
Snoozed: lipgloss.NewStyle().Foreground(t.Muted).Italic(true),
SevCritical: lipgloss.NewStyle().Foreground(t.SevCritical).Bold(true),
SevError: lipgloss.NewStyle().Foreground(t.SevError).Bold(true),
SevWarning: lipgloss.NewStyle().Foreground(t.SevWarning),
SevInfo: lipgloss.NewStyle().Foreground(t.SevInfo),
theme: t,
}
}
// Severity picks the style for a severity label, falling back to muted for
// values this client does not recognise rather than dropping them. // values this client does not recognise rather than dropping them.
func severityStyle(severity string) lipgloss.Style { func (s Styles) Severity(severity string) lipgloss.Style {
switch strings.ToLower(severity) { switch strings.ToLower(severity) {
case "critical": case "critical":
return styleSevCritical return s.SevCritical
case "error": case "error":
return styleSevError return s.SevError
case "warning": case "warning":
return styleSevWarning return s.SevWarning
case "info": case "info":
return styleSevInfo return s.SevInfo
default: default:
return styleMuted return s.Muted
} }
} }
// incidentStatusStyle picks the style for an incident status, falling back to // IncidentStatus picks the style for an incident status, falling back to muted
// muted for statuses added after this client was built. // for statuses added after this client was built.
func incidentStatusStyle(status string) lipgloss.Style { func (s Styles) IncidentStatus(status string) lipgloss.Style {
switch status { switch status {
case api.StatusTriggered: case api.StatusTriggered:
return styleTriggered return s.Triggered
case api.StatusAcknowledged: case api.StatusAcknowledged:
return styleAcknowledged return s.Acknowledged
case api.StatusResolved: case api.StatusResolved:
return styleResolved return s.Resolved
default: default:
return styleMuted return s.Muted
} }
} }
// ── Embedded bubbles components ────────────────────────────────────────────
//
// Each ships its own hardcoded palette, so a theme that stopped at this
// package's own styles would leave a pink selected row and grey help text
// behind. These three restyle them from the same tokens.
// Table styles the six tables. Padding comes from the bubbles defaults; only
// the colours are ours.
func (s Styles) Table() table.Styles {
ts := table.DefaultStyles()
ts.Header = ts.Header.Foreground(s.theme.Muted).Bold(true)
// Cell deliberately keeps no foreground: bubbles renders each cell before
// wrapping the whole row in Selected, so a colour here would emit a reset
// mid-row and cut the selection highlight short.
ts.Selected = ts.Selected.
Foreground(s.theme.OnPrimary).
Background(s.theme.Primary).
Bold(true)
return ts
}
// Help styles the key hints in the footer.
func (s Styles) Help() help.Styles {
key := lipgloss.NewStyle().Foreground(s.theme.Text)
desc := lipgloss.NewStyle().Foreground(s.theme.Muted)
sep := lipgloss.NewStyle().Foreground(s.theme.Muted)
return help.Styles{
Ellipsis: sep,
ShortKey: key,
ShortDesc: desc,
ShortSeparator: sep,
FullKey: key,
FullDesc: desc,
FullSeparator: sep,
}
}
// Input styles a text input and returns it, so NewModel can wrap each one as
// it is built.
func (s Styles) Input(ti textinput.Model) textinput.Model {
ti.PromptStyle = lipgloss.NewStyle().Foreground(s.theme.Primary)
ti.TextStyle = lipgloss.NewStyle().Foreground(s.theme.Text)
ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(s.theme.Muted)
ti.CompletionStyle = lipgloss.NewStyle().Foreground(s.theme.Muted)
ti.Cursor.Style = lipgloss.NewStyle().Foreground(s.theme.Primary)
return ti
}
+131
View File
@@ -0,0 +1,131 @@
package tui
import (
"testing"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/lipgloss"
)
// Assertions here read the colours off the styles rather than off rendered
// output: under `go test` stdout is not a TTY, so lipgloss strips every escape
// sequence and rendered strings would all compare equal.
func wantFg(t *testing.T, s lipgloss.Style, want lipgloss.Color, what string) {
t.Helper()
if got := s.GetForeground(); got != want {
t.Errorf("%s foreground = %v, want %v", what, got, want)
}
}
func TestStyles_TokensReachTheStyles(t *testing.T) {
th := theme.GruvboxDark
s := newStyles(th)
wantFg(t, s.Header, th.Primary, "header")
wantFg(t, s.Firing, th.Firing, "firing")
wantFg(t, s.Resolved, th.Resolved, "resolved")
wantFg(t, s.Muted, th.Muted, "muted")
wantFg(t, s.Status, th.Accent, "status")
wantFg(t, s.Error, th.Error, "error")
wantFg(t, s.SevWarning, th.SevWarning, "sev warning")
// The two inverted spots need the pair, not just a foreground.
wantFg(t, s.TabActive, th.OnPrimary, "active tab")
if got := s.TabActive.GetBackground(); got != th.Primary {
t.Errorf("active tab background = %v, want %v", got, th.Primary)
}
// Attributes the old package-level styles carried must survive.
if !s.Firing.GetBold() {
t.Error("firing lost its bold")
}
if !s.Snoozed.GetItalic() {
t.Error("snoozed lost its italic")
}
if top, right, bottom, left := s.TabActive.GetPadding(); top != 0 || right != 2 || bottom != 0 || left != 2 {
t.Errorf("active tab padding = %d %d %d %d, want 0 2 0 2", top, right, bottom, left)
}
}
func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) {
for _, th := range []theme.Theme{theme.GruvboxDark, theme.GruvboxLight} {
s := newStyles(th)
for what, style := range map[string]lipgloss.Style{
"header": s.Header, "tab inactive": s.TabInactive, "footer": s.Footer,
"status": s.Status, "error": s.Error, "firing": s.Firing,
"resolved": s.Resolved, "muted": s.Muted, "accent": s.Accent,
"alert name": s.AlertName, "bold": s.Bold, "selected": s.Selected,
"triggered": s.Triggered, "acknowledged": s.Acknowledged, "snoozed": s.Snoozed,
"sev critical": s.SevCritical, "sev error": s.SevError,
"sev warning": s.SevWarning, "sev info": s.SevInfo,
} {
if style.GetForeground() == (lipgloss.NoColor{}) {
t.Errorf("%s: %s has no foreground", th.Name, what)
}
}
}
}
func TestStyles_SeverityFallsBackToMuted(t *testing.T) {
s := newStyles(theme.GruvboxDark)
for _, sev := range []string{"critical", "CRITICAL", "error", "warning", "info"} {
if s.Severity(sev).GetForeground() == s.Muted.GetForeground() {
t.Errorf("severity %q should have its own colour", sev)
}
}
for _, sev := range []string{"", "page", "unknown"} {
if s.Severity(sev).GetForeground() != s.Muted.GetForeground() {
t.Errorf("severity %q should fall back to muted", sev)
}
}
}
func TestStyles_IncidentStatusFallsBackToMuted(t *testing.T) {
s := newStyles(theme.GruvboxDark)
for _, status := range []string{api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved} {
if s.IncidentStatus(status).GetForeground() == s.Muted.GetForeground() {
t.Errorf("status %q should have its own colour", status)
}
}
if s.IncidentStatus("invented-later").GetForeground() != s.Muted.GetForeground() {
t.Error("an unknown status should fall back to muted")
}
}
// The bubbles components ship their own palettes — a pink selected row, grey
// help text, a 240 placeholder. These check we replaced them.
func TestStyles_BubblesComponentsFollowTheTheme(t *testing.T) {
th := theme.GruvboxDark
s := newStyles(th)
ts := s.Table()
wantFg(t, ts.Selected, th.OnPrimary, "table selection")
if got := ts.Selected.GetBackground(); got != th.Primary {
t.Errorf("table selection background = %v, want %v", got, th.Primary)
}
wantFg(t, ts.Header, th.Muted, "table header")
if _, right, _, left := ts.Cell.GetPadding(); right != 1 || left != 1 {
t.Error("table cell padding was lost")
}
// A foreground on Cell would emit a reset mid-row and truncate the
// selection highlight, so it must stay unset.
if ts.Cell.GetForeground() != (lipgloss.NoColor{}) {
t.Error("table cells must not carry a foreground")
}
h := s.Help()
wantFg(t, h.ShortKey, th.Text, "help key")
wantFg(t, h.ShortDesc, th.Muted, "help description")
wantFg(t, h.FullSeparator, th.Muted, "help separator")
in := s.Input(textinput.New())
wantFg(t, in.PlaceholderStyle, th.Muted, "input placeholder")
wantFg(t, in.TextStyle, th.Text, "input text")
wantFg(t, in.PromptStyle, th.Primary, "input prompt")
wantFg(t, in.Cursor.Style, th.Primary, "input cursor")
}
+6 -6
View File
@@ -4,10 +4,10 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/atotto/clipboard" "github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
) )
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -468,7 +468,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
cursor := m.scheduleTable.Cursor() cursor := m.scheduleTable.Cursor()
if cursor >= len(m.scheduleDays) { if cursor < 0 || cursor >= len(m.scheduleDays) {
return m, nil return m, nil
} }
day := m.scheduleDays[cursor] day := m.scheduleDays[cursor]
@@ -484,7 +484,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
cursor := m.userManageTable.Cursor() cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) { if cursor < 0 || cursor >= len(m.users) {
return m, nil return m, nil
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
@@ -510,7 +510,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
cursor := m.userManageTable.Cursor() cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) { if cursor < 0 || cursor >= len(m.users) {
return m, nil return m, nil
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
@@ -527,7 +527,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil return m, nil
} }
cursor := m.userManageTable.Cursor() cursor := m.userManageTable.Cursor()
if cursor >= len(m.users) { if cursor < 0 || cursor >= len(m.users) {
return m, nil return m, nil
} }
m.selectedUser = m.users[cursor] m.selectedUser = m.users[cursor]
@@ -893,7 +893,7 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
} }
scheduleCursor := m.scheduleTable.Cursor() scheduleCursor := m.scheduleTable.Cursor()
if scheduleCursor >= len(m.scheduleDays) { if scheduleCursor < 0 || scheduleCursor >= len(m.scheduleDays) {
m.mode = modeDashboard m.mode = modeDashboard
return m, nil return m, nil
} }
+50 -2
View File
@@ -5,8 +5,9 @@ import (
"testing" "testing"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
) )
// press sends one key and returns the resulting model and command. A nil command // press sends one key and returns the resulting model and command. A nil command
@@ -31,7 +32,7 @@ func press(t *testing.T, m Model, key string) (Model, tea.Cmd) {
// sized returns a connected model with a usable window, which most handlers need. // sized returns a connected model with a usable window, which most handlers need.
func sized() Model { func sized() Model {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.width, m.height = 120, 40 m.width, m.height = 120, 40
m.connected = true m.connected = true
return m return m
@@ -676,3 +677,50 @@ func containsAll(s string, subs ...string) bool {
} }
return true return true
} }
// The bug: assigning an on-call week panicked with "index out of range [-1]"
// on a perfectly normal schedule, as long as nobody had moved the cursor first.
//
// The cause is not in this package. bubbles' SetRows clamps the cursor down but
// never up, so the empty rebuild every table gets from the first WindowSizeMsg
// -- which arrives before any fetch returns -- pins the cursor at -1, and
// loading real rows afterwards leaves it there. Pressing up or down hid it,
// which is why every existing test missed it: they all call SetCursor, and
// SetCursor clamps.
//
// So this test must NOT touch the cursor. It reproduces the real order of
// events: size first, data second, keys third.
func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true
m.activeSection = sectionSchedule
m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC)
// 1. Terminal size arrives while every table is still empty.
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = next.(Model)
// 2. The schedule and the user list land.
next, _ = m.Update(scheduleFetchedMsg{entries: []api.ScheduleEntry{}})
m = next.(Model)
next, _ = m.Update(usersFetchedMsg{users: []api.User{
{ID: 1, Username: "niklas", Email: "n@example.com"},
}})
m = next.(Model)
if got := m.scheduleTable.Cursor(); got < 0 {
t.Fatalf("schedule cursor is %d after loading %d days; a populated table must have a usable cursor",
got, len(m.scheduleDays))
}
// 3. Assign the week to the first user, without ever moving a cursor.
m, _ = press(t, m, "W")
if m.mode != modeUserPicker {
t.Fatalf("W did not open the user picker, got mode %v", m.mode)
}
m, _ = press(t, m, "enter") // panicked here
if m.mode == modeUserPicker {
t.Fatal("enter left the picker open; the assignment never went anywhere")
}
}
+119 -119
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/yeniklas/terdut-tui/internal/api"
) )
// Order must match the section constants — renderTabs indexes this by ordinal. // Order must match the section constants — renderTabs indexes this by ordinal.
@@ -26,8 +26,8 @@ func (m Model) View() string {
} }
func (m Model) renderHeader() string { func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui") title := m.styles.Header.Render("terdut-tui")
right := styleMuted.Render(m.serverURL) right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width) return spread(title, right, m.width)
} }
@@ -35,31 +35,31 @@ func (m Model) renderTabs() string {
var tabs []string var tabs []string
for i, name := range sectionNames { for i, name := range sectionNames {
if section(i) == m.activeSection { if section(i) == m.activeSection {
tabs = append(tabs, styleTabActive.Render(name)) tabs = append(tabs, m.styles.TabActive.Render(name))
} else { } else {
tabs = append(tabs, styleTabInactive.Render(name)) tabs = append(tabs, m.styles.TabInactive.Render(name))
} }
} }
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
return strings.Join(tabs, "") + "\n" + sep return strings.Join(tabs, "") + "\n" + sep
} }
func (m Model) renderBody() string { func (m Model) renderBody() string {
if m.err != nil { if m.err != nil {
return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) + return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + styleMuted.Render(" Press r to retry.") "\n" + m.styles.Muted.Render(" Press r to retry.")
} }
if !m.connected { if !m.connected {
return "\n" + styleMuted.Render(" Connecting…") return "\n" + m.styles.Muted.Render(" Connecting…")
} }
switch m.mode { switch m.mode {
case modeIncidentDetail, modeAlertDetail: case modeIncidentDetail, modeAlertDetail:
return m.renderDetail() return m.renderDetail()
case modeNote: case modeNote:
return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View()) return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View())
case modeSnooze: case modeSnooze:
return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View()) return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm: case modeConfirm:
switch m.confirmTarget { switch m.confirmTarget {
case confirmDeleteNote, confirmResolveIncident: case confirmDeleteNote, confirmResolveIncident:
@@ -90,9 +90,9 @@ func (m Model) renderBody() string {
func (m Model) renderFooter() string { func (m Model) renderFooter() string {
withStatus := func(actions string) string { withStatus := func(actions string) string {
rendered := styleFooter.Render(actions) rendered := m.styles.Footer.Render(actions)
if m.statusMsg != "" { if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered return m.styles.Status.Render(" "+m.statusMsg) + "\n" + rendered
} }
return "\n" + rendered return "\n" + rendered
} }
@@ -108,13 +108,13 @@ func (m Model) renderFooter() string {
return withStatus(" i·open incident esc·back") return withStatus(" i·open incident esc·back")
case modeNote: case modeNote:
return "\n" + styleFooter.Render(" enter·submit esc·cancel") return "\n" + m.styles.Footer.Render(" enter·submit esc·cancel")
case modeSnooze: case modeSnooze:
return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)") return "\n" + m.styles.Footer.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
case modeConfirm: case modeConfirm:
return "\n" + styleError.Render(" "+m.confirmPrompt()) return "\n" + m.styles.Error.Render(" "+m.confirmPrompt())
case modeUserPicker: case modeUserPicker:
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
@@ -159,7 +159,7 @@ func (m Model) renderFooter() string {
case sectionUsers: case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit") return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit")
} }
return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
} }
} }
@@ -241,9 +241,9 @@ func (m Model) renderIncidents() string {
var content string var content string
switch { switch {
case m.loading && len(m.incidents) == 0: case m.loading && len(m.incidents) == 0:
content = styleMuted.Render(" Loading incidents…") content = m.styles.Muted.Render(" Loading incidents…")
case len(m.incidents) == 0: case len(m.incidents) == 0:
content = styleMuted.Render( content = m.styles.Muted.Render(
fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter))) fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter)))
default: default:
content = m.incidentTable.View() content = m.incidentTable.View()
@@ -256,9 +256,9 @@ func (m Model) renderAlerts() string {
var content string var content string
switch { switch {
case m.loading && len(m.alerts) == 0: case m.loading && len(m.alerts) == 0:
content = styleMuted.Render(" Loading alerts…") content = m.styles.Muted.Render(" Loading alerts…")
case len(m.alerts) == 0: case len(m.alerts) == 0:
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter))) content = m.styles.Muted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter)))
default: default:
content = m.alertTable.View() content = m.alertTable.View()
} }
@@ -267,10 +267,10 @@ func (m Model) renderAlerts() string {
func (m Model) renderArchived() string { func (m Model) renderArchived() string {
if m.archivedLoading { if m.archivedLoading {
return "\n" + styleMuted.Render(" Loading archived incidents…") return "\n" + m.styles.Muted.Render(" Loading archived incidents…")
} }
if len(m.archivedIncidents) == 0 { if len(m.archivedIncidents) == 0 {
return "\n" + styleMuted.Render(" No archived incidents.") return "\n" + m.styles.Muted.Render(" No archived incidents.")
} }
return "\n" + m.archivedTable.View() return "\n" + m.archivedTable.View()
} }
@@ -286,12 +286,12 @@ func (m Model) renderIncidentStatsBar() string {
mttr = humanSeconds(m.incidentStats.MTTRSeconds) mttr = humanSeconds(m.incidentStats.MTTRSeconds)
} }
left := fmt.Sprintf(" %s %s %s %s", left := fmt.Sprintf(" %s %s %s %s",
styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)), m.styles.Triggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)), m.styles.Acknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)), m.styles.Muted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
) )
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter))) right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter)))
return spread(left, right, m.width) return spread(left, right, m.width)
} }
@@ -304,10 +304,10 @@ func (m Model) renderAlertStatsBar() string {
} }
left := fmt.Sprintf(" Total: %d %s %s", left := fmt.Sprintf(" Total: %d %s %s",
total, total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)), m.styles.Firing.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
) )
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter))) right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter)))
return spread(left, right, m.width) return spread(left, right, m.width)
} }
@@ -324,20 +324,20 @@ func spread(left, right string, width int) string {
func (m Model) renderSchedule() string { func (m Model) renderSchedule() string {
if m.scheduleLoading { if m.scheduleLoading {
return "\n" + styleMuted.Render(" Loading schedule…") return "\n" + m.styles.Muted.Render(" Loading schedule…")
} }
var onCallLine string var onCallLine string
if m.currentOnCall != nil { if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s", onCallLine = fmt.Sprintf(" On-call today: %s",
styleAlertName.Render(m.currentOnCall.Username)) m.styles.AlertName.Render(m.currentOnCall.Username))
} else { } else {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled") onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
} }
from := m.scheduleWindow from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6) to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s", windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006"))) from.Format("Jan 02"), to.Format("Jan 02, 2006")))
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n" header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
@@ -346,12 +346,12 @@ func (m Model) renderSchedule() string {
func (m Model) renderUserPicker() string { func (m Model) renderUserPicker() string {
if m.usersLoading { if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…") return "\n" + m.styles.Muted.Render(" Loading users…")
} }
if m.pickerTarget == pickerIncidentAssignee { if m.pickerTarget == pickerIncidentAssignee {
header := fmt.Sprintf("\n Assign %s to:\n\n", header := fmt.Sprintf("\n Assign %s to:\n\n",
styleBold.Render(m.selectedIncident.Title)) m.styles.Bold.Render(m.selectedIncident.Title))
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
@@ -376,7 +376,7 @@ func (m Model) renderUserPicker() string {
} }
} }
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope)) header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", m.styles.Bold.Render(scope))
return header + m.userPickerTable.View() return header + m.userPickerTable.View()
} }
@@ -384,14 +384,14 @@ func (m Model) renderUserPicker() string {
func (m Model) renderDetail() string { func (m Model) renderDetail() string {
if m.detailLoading { if m.detailLoading {
return "\n" + styleMuted.Render(" Loading…") return "\n" + m.styles.Muted.Render(" Loading…")
} }
return m.detailViewport.View() return m.detailViewport.View()
} }
// renderPrompt puts an input line under the detail pane. // renderPrompt puts an input line under the detail pane.
func (m Model) renderPrompt(prompt string) string { func (m Model) renderPrompt(prompt string) string {
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
return m.detailViewport.View() + "\n" + sep + "\n" + prompt return m.detailViewport.View() + "\n" + sep + "\n" + prompt
} }
@@ -401,7 +401,7 @@ func (m Model) renderStats() string {
// Only announce loading before the first result: a background refresh must not // Only announce loading before the first result: a background refresh must not
// blank the page out from under whoever is reading it. // blank the page out from under whoever is reading it.
if m.statsLoading && !m.statsLoaded { if m.statsLoading && !m.statsLoaded {
return "\n" + styleMuted.Render(" Loading statistics…") return "\n" + m.styles.Muted.Render(" Loading statistics…")
} }
return m.statsViewport.View() return m.statsViewport.View()
} }
@@ -418,16 +418,16 @@ func line(style lipgloss.Style, s string) string {
// ── Content builders ─────────────────────────────────────────────────────── // ── Content builders ───────────────────────────────────────────────────────
func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
// Title + status header // Title + status header
title := styleAlertName.Render(inc.Title) title := s.AlertName.Render(inc.Title)
status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc)) status := s.IncidentStatus(inc.Status).Render(incidentStatusLabel(inc))
if inc.Severity != "" { if inc.Severity != "" {
status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity)) status += " " + s.Severity(inc.Severity).Render(strings.ToUpper(inc.Severity))
} }
gap := contentW - lipgloss.Width(title) - lipgloss.Width(status) gap := contentW - lipgloss.Width(title) - lipgloss.Width(status)
if gap < 1 { if gap < 1 {
@@ -440,9 +440,9 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt))) inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
if inc.AssignedTo != "" { if inc.AssignedTo != "" {
b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo))) b.WriteString(fmt.Sprintf(" Assigned: %s\n", s.Bold.Render(inc.AssignedTo)))
} else { } else {
b.WriteString(line(styleMuted, " Assigned: nobody")) b.WriteString(line(s.Muted, " Assigned: nobody"))
} }
if inc.AcknowledgedByID != nil { if inc.AcknowledgedByID != nil {
@@ -450,14 +450,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
if inc.AcknowledgedAt != nil { if inc.AcknowledgedAt != nil {
ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC") ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
} }
b.WriteString(line(styleResolved, b.WriteString(line(s.Resolved,
fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt))) fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt)))
} else { } else {
b.WriteString(line(styleMuted, " Acked: not acknowledged")) b.WriteString(line(s.Muted, " Acked: not acknowledged"))
} }
if inc.IsSnoozed() { if inc.IsSnoozed() {
b.WriteString(line(styleSnoozed, fmt.Sprintf(" Snoozed: until %s (%s)", b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil)))) inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
} }
@@ -470,14 +470,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source)) inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
} }
if inc.ArchivedAt != nil { if inc.ArchivedAt != nil {
b.WriteString(line(styleMuted, " Archived: "+ b.WriteString(line(s.Muted, " Archived: "+
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC"))) inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC")))
} }
b.WriteString("\n") b.WriteString("\n")
// Group labels — the correlation Alertmanager applied. // Group labels — the correlation Alertmanager applied.
if len(inc.GroupLabels) > 0 { if len(inc.GroupLabels) > 0 {
b.WriteString(divider("Grouped By", width)) b.WriteString(divider(s, "Grouped By", width))
for _, k := range sortedKeys(inc.GroupLabels) { for _, k := range sortedKeys(inc.GroupLabels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24)))
} }
@@ -485,14 +485,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
} }
// Member alerts // Member alerts
b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width)) b.WriteString(divider(s, fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
if len(inc.Alerts) == 0 { if len(inc.Alerts) == 0 {
b.WriteString(line(styleMuted, " No alerts.")) b.WriteString(line(s.Muted, " No alerts."))
} else { } else {
for _, a := range inc.Alerts { for _, a := range inc.Alerts {
marker := styleFiring.Render("●") marker := s.Firing.Render("●")
if a.Status != "firing" { if a.Status != "firing" {
marker = styleResolved.Render("✓") marker = s.Resolved.Render("✓")
} }
instance := a.Labels["instance"] instance := a.Labels["instance"]
if instance == "" { if instance == "" {
@@ -500,29 +500,29 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
} }
b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n", b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n",
marker, truncate(a.Name, 28), truncate(instance, 26), marker, truncate(a.Name, 28), truncate(instance, 26),
styleMuted.Render("last seen "+humanAgo(now, a.ReceivedAt)))) s.Muted.Render("last seen "+humanAgo(now, a.ReceivedAt))))
} }
} }
b.WriteString("\n") b.WriteString("\n")
// Timeline — the only history the server keeps. // Timeline — the only history the server keeps.
notes := noteEvents(timeline) notes := noteEvents(timeline)
b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width)) b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
if len(timeline) == 0 { if len(timeline) == 0 {
b.WriteString(line(styleMuted, " Nothing recorded yet.")) b.WriteString(line(s.Muted, " Nothing recorded yet."))
} else { } else {
noteIndex := 0 noteIndex := 0
for _, e := range timeline { for _, e := range timeline {
when := styleMuted.Render(humanAgo(now, e.CreatedAt)) when := s.Muted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote { if e.Type != api.EventNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when)) b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue continue
} }
marker := " " marker := " "
author := styleBold.Render(e.Username) author := s.Bold.Render(e.Username)
if noteIndex == cursor { if noteIndex == cursor {
marker = styleSelected.Render("> ") marker = s.Selected.Render("> ")
author = styleSelected.Render(e.Username) author = s.Selected.Render(e.Username)
} }
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when)) b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
b.WriteString(" " + e.Detail + "\n") b.WriteString(" " + e.Detail + "\n")
@@ -626,21 +626,21 @@ func notifyKind(detail string) string {
return " (" + detail + ")" return " (" + detail + ")"
} }
func buildAlertDetailContent(alert api.Alert, width int) string { func buildAlertDetailContent(s Styles, alert api.Alert, width int) string {
now := time.Now() now := time.Now()
var b strings.Builder var b strings.Builder
contentW := width - 4 contentW := width - 4
name := styleAlertName.Render(alert.Name) name := s.AlertName.Render(alert.Name)
var statusStr string var statusStr string
if alert.Status == "firing" { if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING") statusStr = s.Firing.Render("● FIRING")
} else { } else {
label := "✓ RESOLVED" label := "✓ RESOLVED"
if alert.ResolutionSource != nil { if alert.ResolutionSource != nil {
label += " · " + *alert.ResolutionSource label += " · " + *alert.ResolutionSource
} }
statusStr = styleResolved.Render(label) statusStr = s.Resolved.Render(label)
} }
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr) gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if gap < 1 { if gap < 1 {
@@ -660,15 +660,15 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
if alert.IncidentID != nil { if alert.IncidentID != nil {
b.WriteString(fmt.Sprintf(" Incident: %s %s\n", b.WriteString(fmt.Sprintf(" Incident: %s %s\n",
styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)), s.Bold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
styleMuted.Render("press i to open it"))) s.Muted.Render("press i to open it")))
} else { } else {
b.WriteString(line(styleMuted, " Incident: none")) b.WriteString(line(s.Muted, " Incident: none"))
} }
b.WriteString("\n") b.WriteString("\n")
if len(alert.Labels) > 0 { if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width)) b.WriteString(divider(s, "Labels", width))
for _, k := range sortedKeys(alert.Labels) { for _, k := range sortedKeys(alert.Labels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
} }
@@ -676,7 +676,7 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
if len(alert.Annotations) > 0 { if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width)) b.WriteString(divider(s, "Annotations", width))
for _, k := range sortedKeys(alert.Annotations) { for _, k := range sortedKeys(alert.Annotations) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24))) b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
} }
@@ -684,14 +684,14 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
} }
// Alerts carry no workflow state: it all lives on the incident. // Alerts carry no workflow state: it all lives on the incident.
b.WriteString(divider("", width)) b.WriteString(divider(s, "", width))
b.WriteString(line(styleMuted, b.WriteString(line(s.Muted,
" Alerts are read-only — acknowledge, assign, note and resolve on the incident.")) " Alerts are read-only — acknowledge, assign, note and resolve on the incident."))
return b.String() return b.String()
} }
func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string { func buildStatsContent(s Styles, incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
barWidth := width/2 - 10 barWidth := width/2 - 10
if barWidth < 8 { if barWidth < 8 {
barWidth = 8 barWidth = 8
@@ -704,41 +704,41 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
b.WriteString("\n") b.WriteString("\n")
// Response times first: they are what a rota is actually judged on. // Response times first: they are what a rota is actually judged on.
b.WriteString(divider("Incident Response", width)) b.WriteString(divider(s, "Incident Response", width))
if incidents == nil { if incidents == nil {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} else { } else {
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
styleBold.Render(fmt.Sprintf("%d", incidents.Total)))) s.Bold.Render(fmt.Sprintf("%d", incidents.Total))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered",
styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered)))) s.Triggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged",
styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged)))) s.Acknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved",
styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved)))) s.Resolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge",
styleBold.Render(humanSeconds(incidents.MTTASeconds)))) s.Bold.Render(humanSeconds(incidents.MTTASeconds))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve", b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
styleBold.Render(humanSeconds(incidents.MTTRSeconds)))) s.Bold.Render(humanSeconds(incidents.MTTRSeconds))))
if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil { if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil {
b.WriteString(line(styleMuted, " (— means nothing has been acknowledged or resolved yet)")) b.WriteString(line(s.Muted, " (— means nothing has been acknowledged or resolved yet)"))
} }
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Top Alerts", width)) b.WriteString(divider(s, "Top Alerts", width))
if len(top) == 0 { if len(top) == 0 {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} else { } else {
maxCount := top[0].Count maxCount := top[0].Count
for i, a := range top { for i, a := range top {
bar := styleResolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth))) bar := s.Resolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count)) b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count))
} }
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Alerts by Hour (UTC)", width)) b.WriteString(divider(s, "Alerts by Hour (UTC)", width))
if len(byHour) > 0 { if len(byHour) > 0 {
maxCount := 0 maxCount := 0
for _, h := range byHour { for _, h := range byHour {
@@ -747,15 +747,15 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
} }
} }
for _, h := range byHour { for _, h := range byHour {
bar := styleFiring.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth))) bar := s.Firing.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count)) b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count))
} }
} else { } else {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} }
b.WriteString("\n") b.WriteString("\n")
b.WriteString(divider("Alerts by Day", width)) b.WriteString(divider(s, "Alerts by Day", width))
if len(byDay) > 0 { if len(byDay) > 0 {
maxCount := 0 maxCount := 0
for _, d := range byDay { for _, d := range byDay {
@@ -764,11 +764,11 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
} }
} }
for _, d := range byDay { for _, d := range byDay {
bar := styleAccent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth))) bar := s.Accent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count)) b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count))
} }
} else { } else {
b.WriteString(line(styleMuted, " No data.")) b.WriteString(line(s.Muted, " No data."))
} }
return b.String() return b.String()
@@ -778,22 +778,22 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
func (m Model) renderUsers() string { func (m Model) renderUsers() string {
if m.usersLoading { if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…") return "\n" + m.styles.Muted.Render(" Loading users…")
} }
if len(m.users) == 0 { if len(m.users) == 0 {
return "\n" + styleMuted.Render(" No users found. Press n to create one.") return "\n" + m.styles.Muted.Render(" No users found. Press n to create one.")
} }
return "\n" + m.userManageTable.View() return "\n" + m.userManageTable.View()
} }
func (m Model) renderUserCreate() string { func (m Model) renderUserCreate() string {
header := "\n " + styleBold.Render("Create new user") + "\n\n" header := "\n " + m.styles.Bold.Render("Create new user") + "\n\n"
usernameLabel := " Username: " usernameLabel := " Username: "
emailLabel := " Email: " emailLabel := " Email: "
if m.userFormFocus == 0 { if m.userFormFocus == 0 {
usernameLabel = styleSelected.Render(" Username: ") usernameLabel = m.styles.Selected.Render(" Username: ")
} else { } else {
emailLabel = styleSelected.Render(" Email: ") emailLabel = m.styles.Selected.Render(" Email: ")
} }
return header + return header +
usernameLabel + m.userFormInputs[0].View() + "\n" + usernameLabel + m.userFormInputs[0].View() + "\n" +
@@ -801,59 +801,59 @@ func (m Model) renderUserCreate() string {
} }
func (m Model) renderUserNotifyEdit() string { func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(styleMuted, hint := line(m.styles.Muted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+ " The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
" their incidents then page the server's shared fallback topic, which\n"+ " their incidents then page the server's shared fallback topic, which\n"+
" carries no Acknowledge button.") " carries no Acknowledge button.")
label := styleSelected.Render(" Topic: ") label := m.styles.Selected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
} }
func (m Model) renderAPIKeyMenu() string { func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.") warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
options := "\n" + options := "\n" +
styleAccent.Render(" n") + " · create a new API key\n" + m.styles.Accent.Render(" n") + " · create a new API key\n" +
styleAccent.Render(" r") + " · revoke a key by ID\n" m.styles.Accent.Render(" r") + " · revoke a key by ID\n"
return header + "\n" + warning + options return header + "\n" + warning + options
} }
func (m Model) renderAPIKeyCreate() string { func (m Model) renderAPIKeyCreate() string {
header := fmt.Sprintf("\n New API key for %s\n\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n New API key for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
label := styleSelected.Render(" Key name: ") label := m.styles.Selected.Render(" Key name: ")
return header + label + m.apiKeyNameInput.View() + "\n" return header + label + m.apiKeyNameInput.View() + "\n"
} }
func (m Model) renderAPIKeyReveal() string { func (m Model) renderAPIKeyReveal() string {
sep := styleMuted.Render(strings.Repeat("─", m.width)) sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
warn := styleError.Render(" !! COPY NOW — this key will NEVER be shown again !!") warn := m.styles.Error.Render(" !! COPY NOW — this key will NEVER be shown again !!")
nameLine := fmt.Sprintf(" Key name: %s", styleBold.Render(m.revealedAPIKey.Name)) nameLine := fmt.Sprintf(" Key name: %s", m.styles.Bold.Render(m.revealedAPIKey.Name))
idLine := fmt.Sprintf(" Key ID: %s %s", idLine := fmt.Sprintf(" Key ID: %s %s",
styleBold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)), m.styles.Bold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
styleMuted.Render("(save this — needed for future revocation)")) m.styles.Muted.Render("(save this — needed for future revocation)"))
keyLine := styleResolved.Render(" " + m.revealedAPIKey.Key) keyLine := m.styles.Resolved.Render(" " + m.revealedAPIKey.Key)
return "\n" + sep + "\n\n" + return "\n" + sep + "\n\n" +
warn + "\n\n" + warn + "\n\n" +
nameLine + "\n" + nameLine + "\n" +
idLine + "\n\n" + idLine + "\n\n" +
styleMuted.Render(" Key value:") + "\n" + m.styles.Muted.Render(" Key value:") + "\n" +
keyLine + "\n\n" + keyLine + "\n\n" +
sep + "\n" sep + "\n"
} }
func (m Model) renderAPIKeyRevokeByID() string { func (m Model) renderAPIKeyRevokeByID() string {
header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username)) header := fmt.Sprintf("\n Revoke API key for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(styleMuted, " Enter the integer key ID (shown when the key was created).") hint := line(m.styles.Muted, " Enter the integer key ID (shown when the key was created).")
label := styleSelected.Render(" Key ID: ") label := m.styles.Selected.Render(" Key ID: ")
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n" return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
} }
// ── Helpers ──────────────────────────────────────────────────────────────── // ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string { func divider(s Styles, title string, width int) string {
prefix := "── " prefix := "── "
if title != "" { if title != "" {
prefix += title + " " prefix += title + " "
@@ -862,7 +862,7 @@ func divider(title string, width int) string {
if remaining > 0 { if remaining > 0 {
prefix += strings.Repeat("─", remaining) prefix += strings.Repeat("─", remaining)
} }
return styleMuted.Render(prefix) + "\n" return s.Muted.Render(prefix) + "\n"
} }
func sortedKeys(m map[string]string) []string { func sortedKeys(m map[string]string) []string {
+22 -17
View File
@@ -6,8 +6,9 @@ import (
"testing" "testing"
"time" "time"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
) )
// ansi matches the escape sequences lipgloss emits when it decides the output // ansi matches the escape sequences lipgloss emits when it decides the output
@@ -16,6 +17,10 @@ var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func plain(s string) string { return ansi.ReplaceAllString(s, "") } func plain(s string) string { return ansi.ReplaceAllString(s, "") }
// testStyles is the default theme, so assertions here run against what a user
// with no 'theme:' key actually sees.
func testStyles() Styles { return newStyles(theme.GruvboxDark) }
func mustContain(t *testing.T, got string, wants ...string) { func mustContain(t *testing.T, got string, wants ...string) {
t.Helper() t.Helper()
got = plain(got) got = plain(got)
@@ -52,7 +57,7 @@ func TestIncidentDetail_RendersTheWholeStory(t *testing.T) {
{Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now}, {Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now},
} }
out := buildIncidentDetailContent(inc, timeline, -1, 110) out := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 110)
mustContain(t, out, mustContain(t, out,
"DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL", "DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL",
"Assigned:", "admin", "Assigned:", "admin",
@@ -72,7 +77,7 @@ func TestIncidentDetail_ShowsSnooze(t *testing.T) {
} }
// The exact remaining time is humanUntil's business, not this test's — a few // The exact remaining time is humanUntil's business, not this test's — a few
// microseconds of elapsed clock turn "in 2h" into "in 1h 59m". // microseconds of elapsed clock turn "in 2h" into "in 1h 59m".
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
"TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h") "TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h")
} }
@@ -83,7 +88,7 @@ func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) {
Title: "Noisy", Status: api.StatusTriggered, Title: "Noisy", Status: api.StatusTriggered,
TriggeredAt: time.Now(), SnoozedUntil: &past, TriggeredAt: time.Now(), SnoozedUntil: &past,
} }
if strings.Contains(plain(buildIncidentDetailContent(inc, nil, -1, 110)), "Snoozed:") { if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, -1, 110)), "Snoozed:") {
t.Error("an expired snooze should not be rendered") t.Error("an expired snooze should not be rendered")
} }
} }
@@ -95,18 +100,18 @@ func TestIncidentDetail_ShowsResolutionSource(t *testing.T) {
Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour), Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour),
ResolvedAt: &now, ResolutionSource: &source, ResolvedAt: &now, ResolutionSource: &source,
} }
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110),
"RESOLVED", "Resolved:", "manual") "RESOLVED", "Resolved:", "manual")
} }
func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) { func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), "nobody", "not acknowledged") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "nobody", "not acknowledged")
} }
func TestIncidentDetail_EmptyTimeline(t *testing.T) { func TestIncidentDetail_EmptyTimeline(t *testing.T) {
inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()}
mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), "Nothing recorded yet") mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "Nothing recorded yet")
} }
func TestIncidentDetail_MarksSelectedNote(t *testing.T) { func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
@@ -117,7 +122,7 @@ func TestIncidentDetail_MarksSelectedNote(t *testing.T) {
} }
inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now} inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now}
out := plain(buildIncidentDetailContent(inc, timeline, 1, 110)) out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, 1, 110))
for _, line := range strings.Split(out, "\n") { for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") { if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") {
t.Errorf("expected the selected note marked, got %q", line) t.Errorf("expected the selected note marked, got %q", line)
@@ -207,7 +212,7 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) {
Detail: "reminder: ntfy returned 502", CreatedAt: now}, Detail: "reminder: ntfy returned 502", CreatedAt: now},
} }
got := buildIncidentDetailContent(inc, timeline, -1, 120) got := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 120)
mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed")
} }
@@ -230,7 +235,7 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
Labels: map[string]string{"instance": "node-1", "severity": "critical"}, Labels: map[string]string{"instance": "node-1", "severity": "critical"},
Annotations: map[string]string{"summary": "disk 90%"}, Annotations: map[string]string{"summary": "disk 90%"},
} }
mustContain(t, buildAlertDetailContent(alert, 110), mustContain(t, buildAlertDetailContent(testStyles(), alert, 110),
"DiskFull", "FIRING", "Incident:", "#7", "press i to open it", "DiskFull", "FIRING", "Incident:", "#7", "press i to open it",
"instance", "node-1", "summary", "disk 90%", "instance", "node-1", "summary", "disk 90%",
"Alerts are read-only") "Alerts are read-only")
@@ -238,21 +243,21 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) {
func TestAlertDetail_NoIncident(t *testing.T) { func TestAlertDetail_NoIncident(t *testing.T) {
alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()} alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()}
mustContain(t, buildAlertDetailContent(alert, 110), "Incident:", "none") mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "Incident:", "none")
} }
func TestAlertDetail_ShowsResolutionSource(t *testing.T) { func TestAlertDetail_ShowsResolutionSource(t *testing.T) {
source := "expiry" source := "expiry"
alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(), alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(),
ResolutionSource: &source} ResolutionSource: &source}
mustContain(t, buildAlertDetailContent(alert, 110), "RESOLVED", "expiry") mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "RESOLVED", "expiry")
} }
// Null MTTA means nothing has been acknowledged, which is a different claim // Null MTTA means nothing has been acknowledged, which is a different claim
// from an instant response. // from an instant response.
func TestStats_RendersDashForMissingAverages(t *testing.T) { func TestStats_RendersDashForMissingAverages(t *testing.T) {
stats := &api.IncidentStats{Total: 2, Triggered: 2} stats := &api.IncidentStats{Total: 2, Triggered: 2}
out := buildStatsContent(stats, nil, nil, nil, 110) out := buildStatsContent(testStyles(), stats, nil, nil, nil, 110)
mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—", mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—",
"nothing has been acknowledged or resolved yet") "nothing has been acknowledged or resolved yet")
} }
@@ -260,12 +265,12 @@ func TestStats_RendersDashForMissingAverages(t *testing.T) {
func TestStats_RendersAverages(t *testing.T) { func TestStats_RendersAverages(t *testing.T) {
mtta, mttr := 150.0, 3600.0 mtta, mttr := 150.0, 3600.0
stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr} stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr}
out := buildStatsContent(stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110) out := buildStatsContent(testStyles(), stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110)
mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull") mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull")
} }
func TestStats_HandlesNoIncidentData(t *testing.T) { func TestStats_HandlesNoIncidentData(t *testing.T) {
mustContain(t, buildStatsContent(nil, nil, nil, nil, 110), "Incident Response", "No data") mustContain(t, buildStatsContent(testStyles(), nil, nil, nil, nil, 110), "Incident Response", "No data")
} }
func TestView_TabsAndDashboardRender(t *testing.T) { func TestView_TabsAndDashboardRender(t *testing.T) {
@@ -296,7 +301,7 @@ func TestView_EmptyStates(t *testing.T) {
// The stats page renders inside the normal section chrome now, so it has to // The stats page renders inside the normal section chrome now, so it has to
// survive the real path: a window size message sizes the viewport and fills it. // survive the real path: a window size message sizes the viewport and fills it.
func TestView_StatsSectionRendersInPlace(t *testing.T) { func TestView_StatsSectionRendersInPlace(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
m.connected = true m.connected = true
m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1} m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1}
m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}} m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}}
@@ -335,7 +340,7 @@ func TestFooter_IncidentDetailOffersResolveOnlyWhileOpen(t *testing.T) {
} }
func TestView_ZeroWidthRendersNothing(t *testing.T) { func TestView_ZeroWidthRendersNothing(t *testing.T) {
m := NewModel(nil, "http://test", time.Minute) m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark)
if m.View() != "" { if m.View() != "" {
t.Error("expected no output before the first window size message") t.Error("expected no output before the first window size message")
} }
+10 -3
View File
@@ -12,7 +12,14 @@ import (
"strings" "strings"
) )
const releaseAPI = "https://api.github.com/repos/yeniklas/terdut-tui/releases/latest" // Gitea's release payload carries the same tag_name, and its attachments the same name
// and browser_download_url, so the types below are unchanged from the GitHub original.
//
// A binary installed before the move still polls api.github.com and will never see a
// release published here. That GitHub repository is still in place, so such a build
// reports itself up to date rather than erroring -- its last GitHub release is the
// bridge, and crossing it is a one-time manual download.
const releaseAPI = "https://git.ryuvia.com/api/v1/repos/niklas/terdut-tui/releases/latest"
type release struct { type release struct {
TagName string `json:"tag_name"` TagName string `json:"tag_name"`
@@ -125,7 +132,7 @@ func fetchLatest() (*release, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
@@ -134,7 +141,7 @@ func fetchLatest() (*release, error) {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GitHub API returned %s", resp.Status) return nil, fmt.Errorf("Gitea API returned %s", resp.Status)
} }
var rel release var rel release
+12 -5
View File
@@ -5,11 +5,12 @@ import (
"fmt" "fmt"
"os" "os"
"git.ryuvia.com/niklas/terdut-tui/internal/api"
"git.ryuvia.com/niklas/terdut-tui/internal/config"
"git.ryuvia.com/niklas/terdut-tui/internal/theme"
"git.ryuvia.com/niklas/terdut-tui/internal/tui"
"git.ryuvia.com/niklas/terdut-tui/internal/updater"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/yeniklas/terdut-tui/internal/api"
"github.com/yeniklas/terdut-tui/internal/config"
"github.com/yeniklas/terdut-tui/internal/tui"
"github.com/yeniklas/terdut-tui/internal/updater"
) )
var version = "dev" var version = "dev"
@@ -38,8 +39,14 @@ func main() {
os.Exit(1) os.Exit(1)
} }
th, err := theme.Load(cfg.Theme)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
client := api.NewClient(cfg.ServerURL, cfg.APIKey) client := api.NewClient(cfg.ServerURL, cfg.APIKey)
model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval) model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th)
p := tea.NewProgram(model, tea.WithAltScreen()) p := tea.NewProgram(model, tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {