Show a colour-coded team picker instead of cycling with T
CI / test (push) Successful in 15s
Release / test (push) Successful in 4s
Release / binaries (push) Successful in 28s

T used to silently cycle Model.activeTeamID through the caller's teams with
no visible list of choices. It now opens a full picker (modelled on the
existing user picker) listing every team plus "All teams", each with a
stable identity colour from a new six-colour theme palette. The same colour
now also shows as a bullet next to "team: <name>" in the header, so the
active team stays visible without opening the picker.

Adds an Identity palette to the theme package (six hues, skipping the ones
that already mean firing/critical), Styles.TeamColor to pick one by team id,
and identity_1..6 as theme-file tokens alongside the existing twelve so a
fully custom theme can still set every token.
This commit is contained in:
Niklas Ye
2026-09-27 18:14:45 +02:00
parent ee25552a53
commit 024dc095a5
11 changed files with 265 additions and 62 deletions
+30 -19
View File
@@ -1,30 +1,36 @@
package theme
import "sort"
import (
"sort"
"github.com/charmbracelet/lipgloss"
)
// 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"
darkBg0 = "#282828"
darkFg1 = "#ebdbb2"
darkGray = "#928374"
darkRed = "#fb4934"
darkGrn = "#b8bb26"
darkYel = "#fabd2f"
darkBlu = "#83a598"
darkAqua = "#8ec07c"
darkOrng = "#fe8019"
darkPurple = "#d3869b"
lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836"
lightFg4 = "#7c6f64"
lightRed = "#9d0006"
lightGrn = "#79740e"
lightYel = "#b57614"
lightBlu = "#076678"
lightAqua = "#427b58"
lightOrng = "#af3a03"
lightBg0 = "#fbf1c7"
lightFg1 = "#3c3836"
lightFg4 = "#7c6f64"
lightRed = "#9d0006"
lightGrn = "#79740e"
lightYel = "#b57614"
lightBlu = "#076678"
lightAqua = "#427b58"
lightOrng = "#af3a03"
lightPurple = "#8f3f71"
)
// GruvboxDark is the default scheme. It assumes a dark terminal background:
@@ -46,6 +52,9 @@ var GruvboxDark = Theme{
SevError: darkOrng,
SevWarning: darkYel,
SevInfo: darkAqua,
// Red already means an alarm, so it is the one gruvbox hue left out here.
Identity: [6]lipgloss.Color{darkBlu, darkAqua, darkYel, darkGrn, darkOrng, darkPurple},
}
// GruvboxLight is the same scheme against a light terminal background.
@@ -66,6 +75,8 @@ var GruvboxLight = Theme{
SevError: lightOrng,
SevWarning: lightYel,
SevInfo: lightAqua,
Identity: [6]lipgloss.Color{lightBlu, lightAqua, lightYel, lightGrn, lightOrng, lightPurple},
}
// Default is the theme used when the config names none.
+13
View File
@@ -32,6 +32,13 @@ type rawTheme struct {
SevError *string `yaml:"sev_error"`
SevWarning *string `yaml:"sev_warning"`
SevInfo *string `yaml:"sev_info"`
Identity1 *string `yaml:"identity_1"`
Identity2 *string `yaml:"identity_2"`
Identity3 *string `yaml:"identity_3"`
Identity4 *string `yaml:"identity_4"`
Identity5 *string `yaml:"identity_5"`
Identity6 *string `yaml:"identity_6"`
}
// binding ties a YAML key to its raw value and the field it fills, so parsing,
@@ -56,6 +63,12 @@ func bindings(r *rawTheme, t *Theme) []binding {
{"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning},
{"sev_info", r.SevInfo, &t.SevInfo},
{"identity_1", r.Identity1, &t.Identity[0]},
{"identity_2", r.Identity2, &t.Identity[1]},
{"identity_3", r.Identity3, &t.Identity[2]},
{"identity_4", r.Identity4, &t.Identity[3]},
{"identity_5", r.Identity5, &t.Identity[4]},
{"identity_6", r.Identity6, &t.Identity[5]},
}
}
+9
View File
@@ -106,6 +106,12 @@ sev_critical: "#000009"
sev_error: "#00000a"
sev_warning: "#00000b"
sev_info: "#00000c"
identity_1: "#00000d"
identity_2: "#00000e"
identity_3: "#00000f"
identity_4: "#000010"
identity_5: "#000011"
identity_6: "#000012"
`)
got, err := loadFrom(dir, "full")
@@ -115,6 +121,9 @@ sev_info: "#00000c"
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
t.Errorf("tokens not applied: %+v", got)
}
if got.Identity[0] != "#00000d" || got.Identity[5] != "#000012" {
t.Errorf("identity tokens not applied: %+v", got.Identity)
}
}
func TestLoadFrom_Errors(t *testing.T) {
+4
View File
@@ -29,4 +29,8 @@ type Theme struct {
SevError lipgloss.Color
SevWarning lipgloss.Color
SevInfo lipgloss.Color
// Identity tells things like teams apart from one another — not a status
// or a severity, so none of the six carries a meaning of its own.
Identity [6]lipgloss.Color
}
+39 -5
View File
@@ -46,6 +46,7 @@ const (
modeSnooze
modeConfirm
modeUserPicker
modeTeamPicker
modeUserCreate
modeUserNotifyEdit
modeAPIKeyMenu
@@ -253,11 +254,12 @@ type Model struct {
// Teams. The server scopes everything to the caller's teams; activeTeamID
// narrows the incident and alert lists to one of them, 0 meaning all. The
// schedule is per team and always needs a concrete one, see scheduleTeam.
teams []api.Team
activeTeamID int64
defaultTeam string // config's `team`, resolved on connect
meID int64
isAdmin bool
teams []api.Team
activeTeamID int64
defaultTeam string // config's `team`, resolved on connect
meID int64
isAdmin bool
teamPickerTable table.Model
// Sign-in. Until the server accepts a session the TUI is in modeLogin;
// loginNote is a line the form shows above the fields (why we are here), and
@@ -396,6 +398,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
pickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
pickerT.SetStyles(ts)
teamPickerT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap()))
teamPickerT.SetStyles(ts)
manageT := table.New(table.WithFocused(true), table.WithKeyMap(tableKeyMap("d", "k", "p")))
manageT.SetStyles(ts)
@@ -499,6 +504,7 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio
scheduleWindow: window,
scheduleTable: schedT,
userPickerTable: pickerT,
teamPickerTable: teamPickerT,
userManageTable: manageT,
userFormInputs: [2]textinput.Model{usernameIn, emailIn},
ntfyTopicInput: topicIn,
@@ -644,6 +650,20 @@ func (m *Model) rebuildUserPickerTable() {
m.userPickerTable.SetHeight(tableHeight(m.height, 10))
}
// rebuildTeamPickerTable lists "All teams" first, then each of the caller's
// teams in the same order switchTeam / selectTeam use, so a row's position
// always matches its place in m.teams.
func (m *Model) rebuildTeamPickerTable() {
m.teamPickerTable.SetColumns(teamPickerColumns(m.width))
rows := make([]table.Row, 0, len(m.teams)+1)
rows = append(rows, table.Row{m.styles.Muted.Render("●"), "All teams", ""})
for _, t := range m.teams {
rows = append(rows, table.Row{m.styles.TeamColor(t.ID).Render("●"), t.Name, t.Role})
}
setRows(&m.teamPickerTable, rows)
m.teamPickerTable.SetHeight(tableHeight(m.height, 10))
}
func (m *Model) rebuildUserManageTable() {
m.userManageTable.SetColumns(userManageColumns(m.width))
rows := make([]table.Row, len(m.users))
@@ -903,6 +923,20 @@ func userPickerColumns(width int) []table.Column {
}
}
func teamPickerColumns(width int) []table.Column {
dotW := 3
roleW := 10
nameW := width - dotW - roleW - 8
if nameW < 15 {
nameW = 15
}
return []table.Column{
{Title: "", Width: dotW},
{Title: "Team", Width: nameW},
{Title: "Role", Width: roleW},
}
}
func userManageColumns(width int) []table.Column {
createdW := 12
usernameW := 25
+11
View File
@@ -125,6 +125,17 @@ func (s Styles) IncidentStatus(status string) lipgloss.Style {
}
}
// TeamColor picks a stable identity colour for a team id, so the same team
// always reads the same colour without the server needing to store one.
func (s Styles) TeamColor(teamID int64) lipgloss.Style {
n := int64(len(s.theme.Identity))
i := teamID % n
if i < 0 { // ids are never negative in practice, but % can still return one
i += n
}
return lipgloss.NewStyle().Foreground(s.theme.Identity[i])
}
// ── Embedded bubbles components ────────────────────────────────────────────
//
// Each ships its own hardcoded palette, so a theme that stopped at this
+30
View File
@@ -66,6 +66,36 @@ func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) {
t.Errorf("%s: %s has no foreground", th.Name, what)
}
}
for i := range th.Identity {
if s.TeamColor(int64(i)).GetForeground() == (lipgloss.NoColor{}) {
t.Errorf("%s: identity %d has no foreground", th.Name, i)
}
}
}
}
// TestStyles_TeamColorIsStableAndDistinct checks the property the header badge
// and the team picker both rely on: the same id always reads the same colour,
// and ids that differ (up to the size of the palette) read as different
// colours rather than all collapsing to one.
func TestStyles_TeamColorIsStableAndDistinct(t *testing.T) {
s := newStyles(theme.GruvboxDark)
seen := make(map[lipgloss.Color]bool)
for id := int64(0); id < 6; id++ {
seen[s.TeamColor(id).GetForeground().(lipgloss.Color)] = true
}
if len(seen) != 6 {
t.Errorf("expected 6 distinct colours across 6 ids, got %d", len(seen))
}
// ids repeat past the palette size, and a negative id (never sent by the
// server, but cheap to guard) must not panic.
if s.TeamColor(6).GetForeground() != s.TeamColor(0).GetForeground() {
t.Error("the palette should wrap rather than index out of range")
}
if got := s.TeamColor(-1).GetForeground(); got == (lipgloss.NoColor{}) {
t.Error("a negative id should still resolve to a colour, not panic or fall back to none")
}
}
+53 -17
View File
@@ -34,6 +34,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.rebuildArchivedTable()
m.rebuildScheduleTable()
m.rebuildUserPickerTable()
m.rebuildTeamPickerTable()
m.rebuildUserManageTable()
m.detailViewport.Width = m.width
m.detailViewport.Height = m.detailViewportHeight()
@@ -400,6 +401,12 @@ func (m Model) routeKey(msg tea.KeyMsg) (Model, tea.Cmd) {
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case modeTeamPicker:
var tableCmd tea.Cmd
m.teamPickerTable, tableCmd = m.teamPickerTable.Update(msg)
m2, ourCmd := m.handleKey(msg)
return m2, tea.Batch(tableCmd, ourCmd)
case modeUserCreate:
var inputCmd tea.Cmd
m.userFormInputs[m.userFormFocus], inputCmd = m.userFormInputs[m.userFormFocus].Update(msg)
@@ -496,6 +503,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m.handleConfirmKey(msg)
case modeUserPicker:
return m.handleUserPickerKey(msg)
case modeTeamPicker:
return m.handleTeamPickerKey(msg)
case modeUserCreate:
return m.handleUserCreateKey(msg)
case modeUserNotifyEdit:
@@ -566,7 +575,7 @@ func (m Model) handleDashboardKey(msg tea.KeyMsg) (Model, tea.Cmd) {
if !m.connected || len(m.teams) == 0 {
return m, nil
}
return m.switchTeam()
return m.openTeamPicker()
case "enter":
switch m.activeSection {
@@ -847,22 +856,21 @@ func (m Model) openUserPicker(target pickerTarget) (Model, tea.Cmd) {
return m, nil
}
// switchTeam steps the active team through all teams, then each of the caller's
// teams in turn, and reloads what depends on it. Sections that are not on screen
// are emptied rather than fetched, so they load when next opened; the incident
// queue is the exception because it is what the caller returns to.
func (m Model) switchTeam() (Model, tea.Cmd) {
next := int64(0)
if m.activeTeamID == 0 {
next = m.teams[0].ID
} else {
for i, t := range m.teams {
if t.ID == m.activeTeamID && i+1 < len(m.teams) {
next = m.teams[i+1].ID
}
}
}
m.activeTeamID = next
// openTeamPicker opens the full list of the caller's teams, plus "All teams",
// for the "T" key to choose among rather than blindly cycling through them.
// Team data is already loaded at connect time, so no fetch is needed.
func (m Model) openTeamPicker() (Model, tea.Cmd) {
m.mode = modeTeamPicker
m.rebuildTeamPickerTable()
return m, nil
}
// selectTeam sets the active team to teamID (0 meaning all teams) and reloads
// what depends on it. Sections that are not on screen are emptied rather than
// fetched, so they load when next opened; the incident queue is the exception
// because it is what the caller returns to.
func (m Model) selectTeam(teamID int64) (Model, tea.Cmd) {
m.activeTeamID = teamID
m.incidents, m.alerts, m.archivedIncidents = nil, nil, nil
m.scheduleEntries, m.scheduleDays, m.currentOnCall = nil, nil, nil
@@ -1253,6 +1261,34 @@ func (m Model) handleUserPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
return m, nil
}
// ── Team picker ───────────────────────────────────────────────────────────
// handleTeamPickerKey reads the cursor's team off the table built by
// rebuildTeamPickerTable, where row 0 is always "All teams" and row i (i>=1)
// is m.teams[i-1].
func (m Model) handleTeamPickerKey(msg tea.KeyMsg) (Model, tea.Cmd) {
switch msg.String() {
case "esc":
m.mode = modeDashboard
return m, nil
case "enter":
cursor := m.teamPickerTable.Cursor()
id := int64(0)
if cursor > 0 {
i := cursor - 1
if i < 0 || i >= len(m.teams) {
return m, nil
}
id = m.teams[i].ID
}
m, cmd := m.selectTeam(id)
m.mode = modeDashboard
return m, cmd
}
return m, nil
}
// scheduleConflicts reports which of dates are already held by somebody other
// than newUserID, and the distinct names holding them.
//
+59 -19
View File
@@ -630,7 +630,7 @@ func TestAlertDetail_JumpToIncident(t *testing.T) {
// A refresh underneath a prompt would move the ground under the user.
func TestRefreshTick_SkipsModalStates(t *testing.T) {
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeUserCreate}
modal := []mode{modeNote, modeSnooze, modeConfirm, modeUserPicker, modeTeamPicker, modeUserCreate}
for _, md := range modal {
m := sized()
m.mode = md
@@ -782,32 +782,38 @@ func TestConnected_DefaultTeamFromConfig(t *testing.T) {
}
}
func TestSwitchTeam_CyclesAllThenEachTeam(t *testing.T) {
func TestTeamPicker_Opens(t *testing.T) {
m := sized()
m.teams = twoTeams()
var seen []int64
for i := 0; i < 4; i++ {
var cmd tea.Cmd
m, cmd = press(t, m, "T")
if cmd == nil {
t.Fatal("switching team should reload")
}
seen = append(seen, m.activeTeamID)
m, cmd := press(t, m, "T")
if m.mode != modeTeamPicker {
t.Fatalf("T should open the team picker, got mode %v", m.mode)
}
want := []int64{1, 2, 0, 1}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("expected the cycle %v, got %v", want, seen)
}
if cmd != nil {
t.Error("opening the picker should not itself trigger a reload")
}
}
func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
// Row 0 of the picker table is always "All teams"; row i (i>=1) is
// m.teams[i-1] — see rebuildTeamPickerTable.
func TestTeamPicker_SelectTeamClearsRowsFromTheOtherTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.incidents = []api.Incident{{ID: 1, Title: "old", TeamName: "Ops"}}
m.rebuildIncidentTable()
m, _ = press(t, m, "T")
m.teamPickerTable.SetCursor(1) // twoTeams()[0] is Ops
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("selecting a team should reload")
}
if m.mode != modeDashboard {
t.Fatalf("enter should close the picker, got mode %v", m.mode)
}
if m.activeTeamID != 1 {
t.Fatalf("expected team 1 (Ops) active, got %d", m.activeTeamID)
}
if len(m.incidents) != 0 {
t.Errorf("the previous team's incidents must not linger, got %d", len(m.incidents))
}
@@ -816,10 +822,44 @@ func TestSwitchTeam_ClearsRowsFromTheOtherTeam(t *testing.T) {
}
}
func TestSwitchTeam_NoTeamsDoesNothing(t *testing.T) {
func TestTeamPicker_SelectAllTeams(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.activeTeamID = 1
m, _ = press(t, m, "T")
m.teamPickerTable.SetCursor(0) // "All teams"
m, cmd := press(t, m, "enter")
if cmd == nil {
t.Fatal("selecting all teams should reload")
}
if m.activeTeamID != 0 {
t.Fatalf("expected all teams (0), got %d", m.activeTeamID)
}
}
func TestTeamPicker_EscCancelsWithoutChangingTeam(t *testing.T) {
m := sized()
m.teams = twoTeams()
m.activeTeamID = 1
m, _ = press(t, m, "T")
m, cmd := press(t, m, "esc")
if cmd != nil {
t.Error("cancelling should not reload")
}
if m.mode != modeDashboard {
t.Fatalf("esc should close the picker, got mode %v", m.mode)
}
if m.activeTeamID != 1 {
t.Fatalf("cancelling must not change the active team, got %d", m.activeTeamID)
}
}
func TestTeamPicker_NoTeamsDoesNothing(t *testing.T) {
m, cmd := press(t, sized(), "T")
if cmd != nil || m.activeTeamID != 0 {
t.Errorf("without teams T has nothing to switch to")
if cmd != nil || m.mode == modeTeamPicker {
t.Errorf("without teams T has nothing to open")
}
}
+15 -1
View File
@@ -28,7 +28,11 @@ func (m Model) View() string {
func (m Model) renderHeader() string {
title := m.styles.Header.Render("terdut-tui")
if len(m.teams) > 0 {
title += m.styles.Muted.Render(" team: " + m.activeTeamLabel())
dot := m.styles.Muted.Render("●")
if t, ok := m.activeTeam(); ok {
dot = m.styles.TeamColor(t.ID).Render("●")
}
title += " " + dot + m.styles.Muted.Render(" team: "+m.activeTeamLabel())
}
right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width)
@@ -153,6 +157,8 @@ func (m Model) renderBody() string {
}
case modeUserPicker:
return m.renderUserPicker()
case modeTeamPicker:
return m.renderTeamPicker()
case modeUserCreate:
return m.renderUserCreate()
case modeUserNotifyEdit:
@@ -210,6 +216,9 @@ func (m Model) renderFooter() string {
}
return withStatus(fmt.Sprintf(" j/k·navigate enter·assign %s esc·cancel", scope))
case modeTeamPicker:
return withStatus(" j/k·navigate enter·select esc·cancel")
case modeUserCreate:
return withStatus(" tab·next field enter·create esc·cancel")
@@ -496,6 +505,11 @@ func (m Model) renderUserPicker() string {
return header + m.userPickerTable.View()
}
func (m Model) renderTeamPicker() string {
header := "\n " + m.styles.Bold.Render("Select a team:") + "\n\n"
return header + m.teamPickerTable.View()
}
// ── Detail ─────────────────────────────────────────────────────────────────
func (m Model) renderDetail() string {