Skip to content

Commit 8508a5f

Browse files
authored
Improved "Login with device" screen (#59)
Co-authored-by: Tomas Vesely <448809+wham@users.noreply.github.com>
1 parent 5f05e5b commit 8508a5f

4 files changed

Lines changed: 173 additions & 69 deletions

File tree

.github/skills/testing/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,10 @@ Use this skill:
1818

1919
## Starting the application
2020

21-
- Run `scripts/run` where the user would normally run `github-brain`
22-
- `scripts/run pull` equivalently runs `github-brain pull`
23-
- `scripts/run mcp` equivalently runs `github-brain mcp`
21+
- Run `scripts/run --test` where the user would normally run `github-brain`
22+
- `scripts/run --test pull` equivalently runs `github-brain pull`
23+
- `scripts/run --test mcp` equivalently runs `github-brain mcp`
24+
- The `--test` flag runs `go vet` before building to catch issues early
2425
- Ensure `.env` files is configured to use the `github-brain-test` organization
2526
- Use GitHub MCP to add new issue/PRs/discussions as needed for testing
2627
- Simulate user input: Send key presses, control combinations, or specific commands to the running application.

main.go

Lines changed: 108 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4960,15 +4960,15 @@ type authCheckResultMsg struct {
49604960
organization string
49614961
}
49624962

4963-
func newMainMenuModel(homeDir string) mainMenuModel {
4963+
func newMainMenuModel(homeDir string, cursor int) mainMenuModel {
49644964
return mainMenuModel{
49654965
homeDir: homeDir,
49664966
choices: []menuChoice{
49674967
{icon: "🔄", name: "Pull", description: "Sync GitHub data to local database"},
49684968
{icon: "🔧", name: "Setup", description: "Configure GitHub username and organization"},
49694969
{icon: "🚪", name: "Exit", description: "Ctrl+C"},
49704970
},
4971-
cursor: 0,
4971+
cursor: cursor,
49724972
status: "Checking authentication...",
49734973
width: 80,
49744974
height: 24,
@@ -5116,8 +5116,9 @@ func RunMainTUI(homeDir string) error {
51165116
return fmt.Errorf("failed to create home directory: %w", err)
51175117
}
51185118

5119+
cursor := 0 // Remember cursor position across menu returns
51195120
for {
5120-
m := newMainMenuModel(homeDir)
5121+
m := newMainMenuModel(homeDir, cursor)
51215122
p := tea.NewProgram(m, tea.WithAltScreen())
51225123

51235124
finalModel, err := p.Run()
@@ -5130,12 +5131,17 @@ func RunMainTUI(homeDir string) error {
51305131
return fmt.Errorf("unexpected model type")
51315132
}
51325133

5134+
cursor = mm.cursor // Remember cursor position
5135+
51335136
if mm.quitting {
51345137
return nil
51355138
}
51365139

51375140
if mm.runSetup {
51385141
if err := RunSetupMenu(homeDir, mm.username, mm.organization); err != nil {
5142+
if err.Error() == "quit" {
5143+
return nil // Exit app cleanly
5144+
}
51395145
// Log error but continue to menu
51405146
slog.Error("Setup failed", "error", err)
51415147
}
@@ -5554,19 +5560,21 @@ type AccessTokenResponse struct {
55545560

55555561
// loginModel is the Bubble Tea model for the login UI
55565562
type loginModel struct {
5557-
spinner spinner.Model
5558-
textInput textinput.Model
5559-
userCode string
5560-
verificationURI string
5561-
status string // "waiting", "org_input", "success", "error"
5562-
errorMsg string
5563-
username string
5564-
token string
5565-
organization string
5566-
homeDir string
5567-
width int
5568-
height int
5569-
done bool
5563+
spinner spinner.Model
5564+
textInput textinput.Model
5565+
userCode string
5566+
verificationURI string
5567+
status string // "waiting", "org_input", "success", "error"
5568+
errorMsg string
5569+
username string
5570+
token string
5571+
organization string
5572+
homeDir string
5573+
width int
5574+
height int
5575+
done bool
5576+
currentUsername string // current logged-in username for title bar
5577+
currentOrg string // current organization for title bar
55705578
}
55715579

55725580
// Login message types
@@ -5584,7 +5592,7 @@ type (
55845592
loginOrgSubmittedMsg struct{}
55855593
)
55865594

5587-
func newLoginModel(homeDir string) loginModel {
5595+
func newLoginModel(homeDir, currentUsername, currentOrg string) loginModel {
55885596
s := spinner.New()
55895597
s.Spinner = spinner.Dot
55905598
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
@@ -5597,12 +5605,14 @@ func newLoginModel(homeDir string) loginModel {
55975605
ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
55985606

55995607
return loginModel{
5600-
spinner: s,
5601-
textInput: ti,
5602-
status: "waiting",
5603-
homeDir: homeDir,
5604-
width: 80,
5605-
height: 24,
5608+
spinner: s,
5609+
textInput: ti,
5610+
status: "waiting",
5611+
homeDir: homeDir,
5612+
width: 80,
5613+
height: 24,
5614+
currentUsername: currentUsername,
5615+
currentOrg: currentOrg,
56065616
}
56075617
}
56085618

@@ -5617,9 +5627,19 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
56175627
case tea.KeyMsg:
56185628
switch msg.String() {
56195629
case "ctrl+c":
5630+
m.status = "quit"
5631+
m.done = true
5632+
return m, tea.Quit
5633+
case "esc":
5634+
m.status = "cancelled"
56205635
m.done = true
56215636
return m, tea.Quit
56225637
case "enter":
5638+
if m.status == "waiting" {
5639+
m.status = "cancelled"
5640+
m.done = true
5641+
return m, tea.Quit
5642+
}
56235643
if m.status == "org_input" {
56245644
m.organization = strings.TrimSpace(m.textInput.Value())
56255645
return m, func() tea.Msg { return loginOrgSubmittedMsg{} }
@@ -5728,35 +5748,40 @@ func (m loginModel) renderWaitingView() string {
57285748
}
57295749
innerWidth := maxContentWidth - 2
57305750

5731-
b.WriteString(renderTitleBar("🔧 Setup", "", "", innerWidth) + "\n")
5732-
b.WriteString("\n")
5733-
b.WriteString("🔐 GitHub Authentication (OAuth)\n")
5751+
b.WriteString(renderTitleBar("🔧 Setup / ✨ Login with device", m.currentUsername, m.currentOrg, innerWidth) + "\n")
57345752
b.WriteString("\n")
57355753

57365754
if m.userCode == "" {
57375755
b.WriteString(m.spinner.View() + " Requesting device code...\n")
57385756
} else {
5739-
b.WriteString("1. Opening browser to: github.com/login/device\n")
5757+
b.WriteString("1. Opening browser to https://github.com/login/device\n")
57405758
b.WriteString("\n")
57415759
b.WriteString("2. Enter this code:\n")
57425760
b.WriteString("\n")
57435761

5744-
// Code box with margin for alignment
5762+
// Code box with double border - gold/yellow stands out against purple
57455763
codeStyle := lipgloss.NewStyle().
5746-
Border(lipgloss.RoundedBorder()).
5747-
BorderForeground(lipgloss.Color("12")).
5748-
Padding(0, 3).
5764+
Border(lipgloss.DoubleBorder()).
5765+
BorderForeground(lipgloss.Color("220")).
5766+
Foreground(lipgloss.Color("220")).
5767+
Padding(0, 4).
57495768
Bold(true).
57505769
MarginLeft(3)
57515770

57525771
b.WriteString(codeStyle.Render(m.userCode) + "\n")
57535772
b.WriteString("\n")
5773+
b.WriteString("3. Grant access to the organizations you are planning to use with GitHub Brain\n")
5774+
b.WriteString("\n")
57545775
b.WriteString(m.spinner.View() + " Waiting for authorization...\n")
57555776
}
57565777

57575778
b.WriteString("\n")
5758-
b.WriteString("Press Ctrl+C to cancel\n")
5759-
b.WriteString("\n")
5779+
5780+
// Back menu item - always selected, same format as Setup screen
5781+
selectorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12"))
5782+
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Bold(true)
5783+
paddedName := fmt.Sprintf("%-4s", "Back")
5784+
b.WriteString(selectorStyle.Render("▶") + " ← " + titleStyle.Render(paddedName) + " " + selectedStyle.Render("Esc"))
57605785

57615786
return b.String()
57625787
}
@@ -5833,14 +5858,14 @@ func (m loginModel) renderErrorView() string {
58335858
}
58345859

58355860
// RunLogin runs the OAuth device flow login
5836-
func RunLogin(homeDir string) error {
5861+
func RunLogin(homeDir, currentUsername, currentOrg string) error {
58375862
// Ensure home directory exists
58385863
if err := os.MkdirAll(homeDir, 0755); err != nil {
58395864
return fmt.Errorf("failed to create home directory: %w", err)
58405865
}
58415866

58425867
// Create the Bubble Tea model
5843-
m := newLoginModel(homeDir)
5868+
m := newLoginModel(homeDir, currentUsername, currentOrg)
58445869
p := tea.NewProgram(m, tea.WithAltScreen())
58455870

58465871
// Run the device flow in a goroutine
@@ -5854,9 +5879,15 @@ func RunLogin(homeDir string) error {
58545879

58555880
// Check if login was successful
58565881
if lm, ok := finalModel.(loginModel); ok {
5882+
if lm.status == "quit" {
5883+
return fmt.Errorf("quit")
5884+
}
58575885
if lm.status == "error" {
58585886
return fmt.Errorf("%s", lm.errorMsg)
58595887
}
5888+
if lm.status == "cancelled" {
5889+
return nil // Go back without error
5890+
}
58605891
if lm.status != "success" {
58615892
return fmt.Errorf("login cancelled")
58625893
}
@@ -5885,18 +5916,18 @@ type setupMenuModel struct {
58855916
goBack bool
58865917
}
58875918

5888-
func newSetupMenuModel(homeDir, username, organization string) setupMenuModel {
5919+
func newSetupMenuModel(homeDir, username, organization string, cursor int) setupMenuModel {
58895920
return setupMenuModel{
58905921
homeDir: homeDir,
58915922
username: username,
58925923
organization: organization,
58935924
choices: []menuChoice{
5894-
{icon: "✨", name: "Login with code", description: "Recommended for organization owners"},
5925+
{icon: "✨", name: "Login with device", description: "Recommended for organization owners"},
58955926
{icon: "🔑", name: "Login with PAT", description: "Works without organization ownership"},
58965927
{icon: "📝", name: "Advanced", description: "Edit configuration file"},
5897-
{icon: "🔙", name: "Back", description: "Esc"},
5928+
{icon: "", name: "Back", description: "Esc"},
58985929
},
5899-
cursor: 0,
5930+
cursor: cursor,
59005931
width: 80,
59015932
height: 24,
59025933
}
@@ -5968,17 +5999,28 @@ func (m setupMenuModel) View() string {
59685999

59696000
// Menu items - same format as Home screen
59706001
selectorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("12")) // Blue selector
6002+
6003+
// Find the longest name for alignment
6004+
maxNameWidth := 0
6005+
for _, choice := range m.choices {
6006+
if len(choice.name) > maxNameWidth {
6007+
maxNameWidth = len(choice.name)
6008+
}
6009+
}
6010+
59716011
for i, choice := range m.choices {
59726012
cursor := " "
59736013
descStyle := dimStyle
59746014
if m.cursor == i {
59756015
cursor = selectorStyle.Render("▶") + " "
59766016
descStyle = selectedStyle
59776017
}
5978-
// Pad name to 15 characters for alignment
5979-
paddedName := fmt.Sprintf("%-15s", choice.name)
6018+
// Pad icon to 2 characters (emoji width) and name for alignment
6019+
iconWidth := lipgloss.Width(choice.icon)
6020+
iconPadding := strings.Repeat(" ", 2-iconWidth)
6021+
paddedName := fmt.Sprintf("%-*s", maxNameWidth, choice.name)
59806022
// Name is always bold (titleStyle), description uses current selection style
5981-
b.WriteString(fmt.Sprintf("%s%s %s %s", cursor, choice.icon, titleStyle.Render(paddedName), descStyle.Render(choice.description)))
6023+
b.WriteString(fmt.Sprintf("%s%s%s %s %s", cursor, choice.icon, iconPadding, titleStyle.Render(paddedName), descStyle.Render(choice.description)))
59826024
if i < len(m.choices)-1 {
59836025
b.WriteString("\n\n")
59846026
}
@@ -5996,8 +6038,9 @@ func (m setupMenuModel) View() string {
59966038

59976039
// RunSetupMenu runs the setup submenu
59986040
func RunSetupMenu(homeDir, username, organization string) error {
6041+
cursor := 0 // Remember cursor position across menu returns
59996042
for {
6000-
m := newSetupMenuModel(homeDir, username, organization)
6043+
m := newSetupMenuModel(homeDir, username, organization, cursor)
60016044
p := tea.NewProgram(m, tea.WithAltScreen())
60026045

60036046
finalModel, err := p.Run()
@@ -6010,12 +6053,21 @@ func RunSetupMenu(homeDir, username, organization string) error {
60106053
return fmt.Errorf("unexpected model type")
60116054
}
60126055

6013-
if sm.quitting || sm.goBack {
6056+
cursor = sm.cursor // Remember cursor position
6057+
6058+
if sm.quitting {
6059+
return fmt.Errorf("quit")
6060+
}
6061+
6062+
if sm.goBack {
60146063
return nil
60156064
}
60166065

60176066
if sm.runOAuth {
6018-
if err := RunLogin(homeDir); err != nil {
6067+
if err := RunLogin(homeDir, username, organization); err != nil {
6068+
if err.Error() == "quit" {
6069+
return err // Propagate quit to exit app
6070+
}
60196071
slog.Error("OAuth login failed", "error", err)
60206072
}
60216073
// Reload .env after login
@@ -6026,6 +6078,9 @@ func RunSetupMenu(homeDir, username, organization string) error {
60266078

60276079
if sm.runPAT {
60286080
if err := RunPATLogin(homeDir); err != nil {
6081+
if err.Error() == "quit" {
6082+
return err // Propagate quit to exit app
6083+
}
60296084
slog.Error("PAT login failed", "error", err)
60306085
}
60316086
// Reload .env after login
@@ -6138,9 +6193,11 @@ func (m patLoginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
61386193
case tea.KeyMsg:
61396194
switch msg.String() {
61406195
case "ctrl+c":
6196+
m.status = "quit"
61416197
m.done = true
61426198
return m, tea.Quit
61436199
case "esc":
6200+
m.status = "cancelled"
61446201
m.done = true
61456202
return m, tea.Quit
61466203
case "enter":
@@ -6359,9 +6416,15 @@ func RunPATLogin(homeDir string) error {
63596416

63606417
// Check if login was successful
63616418
if pm, ok := finalModel.(patLoginModel); ok {
6419+
if pm.status == "quit" {
6420+
return fmt.Errorf("quit")
6421+
}
63626422
if pm.status == "error" {
63636423
return fmt.Errorf("%s", pm.errorMsg)
63646424
}
6425+
if pm.status == "cancelled" {
6426+
return nil // Go back without error
6427+
}
63656428
if pm.status != "success" {
63666429
return fmt.Errorf("login cancelled")
63676430
}

0 commit comments

Comments
 (0)