Skip to content
This repository was archived by the owner on Mar 21, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
},
)
}

Expand Down Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions internal/claudeauth/auth.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
131 changes: 131 additions & 0 deletions internal/claudeauth/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
21 changes: 21 additions & 0 deletions internal/ui/footer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
80 changes: 80 additions & 0 deletions internal/ui/footer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading