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
+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 {