diff --git a/internal/app/app.go b/internal/app/app.go index 4116628..1baf1de 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,6 +11,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/zhubert/plural/internal/changelog" "github.com/zhubert/plural/internal/claude" + "github.com/zhubert/plural/internal/claudeauth" "github.com/zhubert/plural/internal/claudeconfig" "github.com/zhubert/plural/internal/clipboard" "github.com/zhubert/plural/internal/config" @@ -201,6 +202,12 @@ type CreateChildRequestMsg struct { Request mcp.CreateChildRequest } +// AuthStatusFetchedMsg is sent when the Claude auth status has been fetched at startup +type AuthStatusFetchedMsg struct { + Status *claudeauth.AuthStatus + Error error +} + // ListChildrenRequestMsg is sent when the supervisor's MCP tool list_child_sessions is called type ListChildrenRequestMsg struct { SessionID string @@ -380,6 +387,10 @@ func (m *Model) Init() tea.Cmd { return StartupModalMsg{} }, PRPollTick(), + func() tea.Msg { + status, err := claudeauth.GetAuthStatus() + return AuthStatusFetchedMsg{Status: status, Error: err} + }, ) } @@ -722,6 +733,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case StartupModalMsg: return m.handleStartupModals() + case AuthStatusFetchedMsg: + if msg.Error == nil && msg.Status != nil { + if display := msg.Status.DisplayString(); display != "" { + m.footer.SetUserInfo(display) + } + } + return m, tea.Batch(cmds...) + case ui.HelpShortcutTriggeredMsg: // Handle shortcut triggered from help modal return m.handleHelpShortcutTrigger(msg.Key) diff --git a/internal/claudeauth/auth.go b/internal/claudeauth/auth.go new file mode 100644 index 0000000..688005c --- /dev/null +++ b/internal/claudeauth/auth.go @@ -0,0 +1,51 @@ +// Package claudeauth provides helpers for querying the Claude CLI authentication status. +package claudeauth + +import ( + "encoding/json" + "os/exec" +) + +// AuthStatus holds the parsed result of `claude auth status --output-format json`. +type AuthStatus struct { + LoggedIn bool `json:"loggedIn"` + AuthMethod string `json:"authMethod"` + Email string `json:"email"` + OrgName string `json:"orgName"` +} + +// GetAuthStatus runs `claude auth status --output-format json` and returns the parsed result. +// It returns a non-nil error if the command is unavailable, fails to run, or if the output +// cannot be parsed as valid JSON. +func GetAuthStatus() (*AuthStatus, error) { + cmd := exec.Command("claude", "auth", "status", "--output-format", "json") + output, err := cmd.Output() + if err != nil { + return nil, err + } + + var status AuthStatus + if err := json.Unmarshal(output, &status); err != nil { + return nil, err + } + + return &status, nil +} + +// DisplayString returns a concise string suitable for display in the UI footer. +// Returns an empty string when there is nothing useful to show. +func (s *AuthStatus) DisplayString() string { + if s == nil || !s.LoggedIn { + return "" + } + if s.Email != "" { + if s.OrgName != "" { + return s.Email + " @ " + s.OrgName + } + return s.Email + } + if s.AuthMethod != "" { + return s.AuthMethod + } + return "" +} diff --git a/internal/claudeauth/auth_test.go b/internal/claudeauth/auth_test.go new file mode 100644 index 0000000..d862f86 --- /dev/null +++ b/internal/claudeauth/auth_test.go @@ -0,0 +1,131 @@ +package claudeauth + +import ( + "encoding/json" + "testing" +) + +func TestAuthStatus_DisplayString(t *testing.T) { + tests := []struct { + name string + status *AuthStatus + expected string + }{ + { + name: "nil status", + status: nil, + expected: "", + }, + { + name: "not logged in", + status: &AuthStatus{LoggedIn: false}, + expected: "", + }, + { + name: "logged in with email only", + status: &AuthStatus{LoggedIn: true, Email: "user@example.com"}, + expected: "user@example.com", + }, + { + name: "logged in with email and org", + status: &AuthStatus{LoggedIn: true, Email: "user2@example.com", OrgName: "Example Org"}, + expected: "user2@example.com @ Example Org", + }, + { + name: "logged in with auth method only", + status: &AuthStatus{LoggedIn: true, AuthMethod: "claude.ai"}, + expected: "claude.ai", + }, + { + name: "logged in but no useful info", + status: &AuthStatus{LoggedIn: true}, + expected: "", + }, + { + name: "email takes priority over auth method", + status: &AuthStatus{LoggedIn: true, Email: "user@example.com", AuthMethod: "api-key"}, + expected: "user@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.status.DisplayString() + if got != tt.expected { + t.Errorf("DisplayString() = %q, want %q", got, tt.expected) + } + }) + } +} + +func TestAuthStatus_JSONParsing(t *testing.T) { + tests := []struct { + name string + jsonInput string + wantErr bool + wantLogin bool + wantEmail string + wantOrg string + wantMethod string + }{ + { + name: "full valid JSON", + jsonInput: `{"loggedIn":true,"authMethod":"claude.ai","email":"user2@example.com","orgName":"Example Org"}`, + wantLogin: true, + wantEmail: "user2@example.com", + wantOrg: "Example Org", + wantMethod: "claude.ai", + }, + { + name: "not logged in", + jsonInput: `{"loggedIn":false}`, + wantLogin: false, + }, + { + name: "missing fields defaults to zero values", + jsonInput: `{"loggedIn":true}`, + wantLogin: true, + }, + { + name: "malformed JSON", + jsonInput: `not valid json`, + wantErr: true, + }, + { + name: "empty input", + jsonInput: ``, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var status AuthStatus + err := json.Unmarshal([]byte(tt.jsonInput), &status) + + if tt.wantErr { + if err == nil { + t.Error("expected error but got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if status.LoggedIn != tt.wantLogin { + t.Errorf("LoggedIn = %v, want %v", status.LoggedIn, tt.wantLogin) + } + if status.Email != tt.wantEmail { + t.Errorf("Email = %q, want %q", status.Email, tt.wantEmail) + } + if status.OrgName != tt.wantOrg { + t.Errorf("OrgName = %q, want %q", status.OrgName, tt.wantOrg) + } + if status.AuthMethod != tt.wantMethod { + t.Errorf("AuthMethod = %q, want %q", status.AuthMethod, tt.wantMethod) + } + }) + } +} diff --git a/internal/ui/footer.go b/internal/ui/footer.go index ef9372b..ee9334f 100644 --- a/internal/ui/footer.go +++ b/internal/ui/footer.go @@ -64,6 +64,7 @@ type Footer struct { hasDetectedOptions bool // Whether chat has detected options for parallel exploration kittyKeyboard bool // Terminal supports Kitty keyboard protocol flashMessage *FlashMessage // Current flash message, if any + userInfo string // Logged-in user info (e.g. email or email @ org) // Dynamic bindings generator (injected from app) getApplicableBindings func() []KeyBinding @@ -98,6 +99,12 @@ func (f *Footer) SetBindingsGenerator(fn func() []KeyBinding) { f.getApplicableBindings = fn } +// SetUserInfo sets the logged-in user info string to display in the footer. +// An empty string hides the user info. +func (f *Footer) SetUserInfo(info string) { + f.userInfo = info +} + // GetApplicableBindings returns the current bindings (for testing) func (f *Footer) GetApplicableBindings() []KeyBinding { if f.getApplicableBindings == nil { @@ -339,6 +346,20 @@ func (f *Footer) View() string { content := strings.Join(parts, footerSeparator()) + // Append right-aligned user info when available. + // Account for horizontal padding applied by FooterStyle (currently Padding(0, 1)). + if f.userInfo != "" { + userInfoStyled := lipgloss.NewStyle().Foreground(ColorTextMuted).Render(f.userInfo) + contentWidth := lipgloss.Width(content) + userInfoWidth := lipgloss.Width(userInfoStyled) + const leftPad, rightPad = 1, 1 + innerWidth := f.width - leftPad - rightPad + padding := innerWidth - contentWidth - userInfoWidth + if padding > 0 { + content = content + strings.Repeat(" ", padding) + userInfoStyled + } + } + // Use MaxHeight(1) to ensure footer never wraps to multiple lines return FooterStyle.Width(f.width).MaxHeight(1).Render(content) } diff --git a/internal/ui/footer_test.go b/internal/ui/footer_test.go index 8d2e62c..138a401 100644 --- a/internal/ui/footer_test.go +++ b/internal/ui/footer_test.go @@ -282,6 +282,86 @@ func TestFooter_MultiSelectMode_FlashTakesPriority(t *testing.T) { } } +func TestFooter_SetUserInfo(t *testing.T) { + footer := NewFooter() + + if footer.userInfo != "" { + t.Error("Expected empty userInfo initially") + } + + footer.SetUserInfo("user@example.com") + if footer.userInfo != "user@example.com" { + t.Errorf("Expected userInfo to be 'user@example.com', got %q", footer.userInfo) + } + + footer.SetUserInfo("") + if footer.userInfo != "" { + t.Error("Expected userInfo to be cleared") + } +} + +func TestFooter_View_UserInfo(t *testing.T) { + footer := NewFooter() + footer.SetWidth(120) + footer.SetBindingsGenerator(func() []KeyBinding { + return []KeyBinding{ + {Key: "n", Desc: "new session"}, + {Key: "q", Desc: "quit"}, + } + }) + footer.SetContext(true, true, false, false, false, false, false, false, false, false) + + // Without user info, should not contain any email + viewWithout := footer.View() + if strings.Contains(viewWithout, "user@example.com") { + t.Error("Should not contain user info when not set") + } + + // With user info, should contain the email + footer.SetUserInfo("user@example.com") + viewWith := footer.View() + if !strings.Contains(viewWith, "user@example.com") { + t.Error("Should contain user info when set") + } +} + +func TestFooter_View_UserInfo_FlashTakesPriority(t *testing.T) { + footer := NewFooter() + footer.SetWidth(120) + footer.SetBindingsGenerator(func() []KeyBinding { + return []KeyBinding{{Key: "q", Desc: "quit"}} + }) + footer.SetContext(true, true, false, false, false, false, false, false, false, false) + footer.SetUserInfo("user@example.com") + footer.SetFlash("Something went wrong", FlashError) + + view := footer.View() + // Flash takes priority: user info should not appear + if strings.Contains(view, "user@example.com") { + t.Error("Flash should take priority over user info") + } + if !strings.Contains(view, "Something went wrong") { + t.Error("Flash message should be visible") + } +} + +func TestFooter_View_UserInfo_WidthTooNarrow(t *testing.T) { + footer := NewFooter() + // Very narrow width — user info should be suppressed rather than overlap keybindings + footer.SetWidth(10) + footer.SetBindingsGenerator(func() []KeyBinding { + return []KeyBinding{{Key: "n", Desc: "new session"}} + }) + footer.SetContext(true, true, false, false, false, false, false, false, false, false) + footer.SetUserInfo("user@example.com") + + // Should not panic; user info is silently dropped when there is no room + view := footer.View() + if strings.Contains(view, "user@example.com") { + t.Error("User info should be suppressed when width is too narrow") + } +} + func TestFooter_NewlineShortcutDisplay(t *testing.T) { footer := NewFooter() footer.SetWidth(120)