From 024dc095a594e43b4d26f40c2b5d1b6d6f35143d Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Sun, 27 Sep 2026 18:14:45 +0200 Subject: [PATCH] Show a colour-coded team picker instead of cycling with T 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: " 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. --- README.md | 3 +- internal/theme/builtin.go | 49 ++++++++++++++--------- internal/theme/load.go | 13 +++++++ internal/theme/load_test.go | 9 +++++ internal/theme/theme.go | 4 ++ internal/tui/model.go | 44 ++++++++++++++++++--- internal/tui/styles.go | 11 ++++++ internal/tui/styles_test.go | 30 ++++++++++++++ internal/tui/update.go | 70 +++++++++++++++++++++++++-------- internal/tui/update_test.go | 78 ++++++++++++++++++++++++++++--------- internal/tui/view.go | 16 +++++++- 11 files changed, 265 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index b9d9e97..e213baa 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ 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: +Without `extends`, every token must be set. The eighteen are: | Token | Where it shows | |---|---| @@ -144,6 +144,7 @@ Without `extends`, every token must be set. The twelve are: | `resolved` | resolved alerts and incidents, the top-alerts chart | | `error` | error banners | | `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label | +| `identity_1` .. `identity_6` | a team's colour in the header and the `T` picker — carries no meaning of its own, so pick six colours that just read as distinct from one another (and from `firing`/`sev_critical`, which already mean something) | 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 diff --git a/internal/theme/builtin.go b/internal/theme/builtin.go index 1682476..84e1f10 100644 --- a/internal/theme/builtin.go +++ b/internal/theme/builtin.go @@ -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. diff --git a/internal/theme/load.go b/internal/theme/load.go index 26f69b7..a9bc2f9 100644 --- a/internal/theme/load.go +++ b/internal/theme/load.go @@ -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]}, } } diff --git a/internal/theme/load_test.go b/internal/theme/load_test.go index 3787810..95385fe 100644 --- a/internal/theme/load_test.go +++ b/internal/theme/load_test.go @@ -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) { diff --git a/internal/theme/theme.go b/internal/theme/theme.go index d2e41e5..724491e 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -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 } diff --git a/internal/tui/model.go b/internal/tui/model.go index c4e1422..764e67b 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 diff --git a/internal/tui/styles.go b/internal/tui/styles.go index f7f25de..312cc14 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -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 diff --git a/internal/tui/styles_test.go b/internal/tui/styles_test.go index 0fd3847..3540256 100644 --- a/internal/tui/styles_test.go +++ b/internal/tui/styles_test.go @@ -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") } } diff --git a/internal/tui/update.go b/internal/tui/update.go index d66abd0..8d6edf9 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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. // diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 2f8975e..6ab876d 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -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") } } diff --git a/internal/tui/view.go b/internal/tui/view.go index e4c09a2..2b9cacf 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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 {