diff --git a/README.md b/README.md index 41eb10e5..cddd2490 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ claws -l debug.log | `P` | Select AWS profile(s) (multi-select supported) | | `?` | Show help | | `Esc` | Go back | -| `Ctrl+c` | Quit | +| `q` / `Ctrl+c` | Quit | ### Mouse Support @@ -219,6 +219,7 @@ Selected profiles are queried in parallel; resources display with Profile and Ac | Command | Action | |---------|--------| +| `:q` / `:quit` | Quit | | `:login [name]` | AWS console login (default: `claws-login` profile) | | `:ec2/instances` | Navigate to EC2 instances | | `:sort ` | Sort by column | diff --git a/internal/app/app.go b/internal/app/app.go index 6c952996..d6c746ef 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -114,7 +114,7 @@ func (a *App) Init() tea.Cmd { func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if a.showWarnings && a.warningsReady { if keyMsg, ok := msg.(tea.KeyPressMsg); ok { - if keyMsg.Code == tea.KeyEnter || keyMsg.String() == " " { + if keyMsg.Code == tea.KeyEnter || keyMsg.String() == "space" || keyMsg.String() == "q" { a.showWarnings = false return a, nil } @@ -162,10 +162,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.commandInput.SetWidth(msg.Width) // Update cached styles with new width a.styles = newAppStyles(msg.Width) - // Mark warnings as ready to be dismissed after first window size (terminal init complete) - if a.showWarnings && !a.warningsReady { - a.warningsReady = true - } + // Mark warnings ready after first WindowSizeMsg (terminal initialized). + // Safe to set unconditionally - only affects dismissal when showWarnings is true. + a.warningsReady = true if a.currentView != nil { return a, a.currentView.SetSize(msg.Width, msg.Height-2) } @@ -203,6 +202,14 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, a.keys.Quit): + switch a.currentView.(type) { + case *view.DetailView, *view.DiffView: + if len(a.viewStack) > 0 { + a.currentView = a.viewStack[len(a.viewStack)-1] + a.viewStack = a.viewStack[:len(a.viewStack)-1] + return a, a.currentView.SetSize(a.width, a.height-2) + } + } return a, tea.Quit case key.Matches(msg, a.keys.Help): @@ -420,7 +427,7 @@ func (a *App) renderWarnings() string { content += s.warningItem.Render("• "+w) + "\n" } - content += "\n" + s.warningDim.Render("Press Enter or Space to continue...") + content += "\n" + s.warningDim.Render("Press Enter, Space, or q to continue...") boxStyle := s.warningBox.Width(a.width - 10) box := boxStyle.Render(content) @@ -456,7 +463,7 @@ func (a *App) handleModalUpdate(msg tea.Msg) (tea.Model, tea.Cmd) { ) case tea.KeyPressMsg: - if view.IsEscKey(msg) || msg.Code == tea.KeyBackspace { + if view.IsEscKey(msg) || msg.Code == tea.KeyBackspace || msg.String() == "q" { if ic, ok := a.modal.Content.(view.InputCapture); ok && ic.HasActiveInput() { break } @@ -533,8 +540,8 @@ func defaultKeyMap() keyMap { key.WithHelp("?", "help"), ), Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), ), } } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 2bb008c6..312909f2 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -301,3 +301,31 @@ func TestModalNavigateClosesModal(t *testing.T) { t.Errorf("Expected viewStack length 1, got %d", len(app.viewStack)) } } + +func TestWarningScreenDismissal(t *testing.T) { + tests := []struct { + name string + key tea.KeyPressMsg + }{ + {"enter", tea.KeyPressMsg{Code: tea.KeyEnter}}, + {"space", tea.KeyPressMsg{Code: tea.KeySpace}}, + {"q", tea.KeyPressMsg{Code: 0, Text: "q"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + app := New(ctx, reg) + app.showWarnings = true + app.warningsReady = true + + app.Update(tt.key) + + if app.showWarnings { + t.Errorf("Expected showWarnings=false after %s key", tt.name) + } + }) + } +} diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 714bdfbd..06db304f 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -217,6 +217,11 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { return nil, &NavigateMsg{View: dashboard, ClearStack: true} } + // Handle quit command + if input == "q" || input == "quit" { + return tea.Quit, nil + } + // Handle services/browse command - go to service browser if input == "services" || input == "browse" { browser := NewServiceBrowser(c.ctx, c.registry) @@ -419,6 +424,9 @@ func (c *CommandInput) GetSuggestions() []string { } else { // Suggest services and special commands // Add navigation commands + if strings.HasPrefix("quit", input) { + suggestions = append(suggestions, "quit") + } if strings.HasPrefix("home", input) { suggestions = append(suggestions, "home") } diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index 9c6b89c7..197f952b 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -160,6 +160,36 @@ func TestCommandInput_Update_Enter_Service(t *testing.T) { } } +func TestCommandInput_QuitCommand(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + tests := []struct { + input string + wantQuit bool + }{ + {"q", true}, + {"quit", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + ci := NewCommandInput(ctx, reg) + ci.Activate() + ci.textInput.SetValue(tt.input) + + cmd, nav := ci.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if cmd == nil { + t.Error("Expected tea.Quit command") + } + if nav != nil { + t.Error("Expected nil NavigateMsg for quit") + } + }) + } +} + // mockDiffProvider for testing getDiffSuggestions type mockDiffProvider struct { names []string diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 6795e791..d8a9c682 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -129,7 +129,7 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return d, nil case tea.KeyPressMsg: - // Let app handle back navigation + // Let app handle back navigation (esc/backspace/q handled by app.go) if IsEscKey(msg) { return d, nil } @@ -255,7 +255,7 @@ func (d *DetailView) StatusLine() string { parts = append(parts, navInfo) } - parts = append(parts, "esc:back") + parts = append(parts, "q/esc:back") return strings.Join(parts, " • ") } diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index bceb23eb..7cc45e31 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -68,8 +68,9 @@ func (d *DiffView) Init() tea.Cmd { func (d *DiffView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: - if IsEscKey(msg) || msg.String() == "q" { - return d, nil // Let app handle back navigation + // Let app handle back navigation (esc/backspace/q handled by app.go) + if IsEscKey(msg) { + return d, nil } } @@ -120,7 +121,7 @@ func (d *DiffView) SetSize(width, height int) tea.Cmd { // StatusLine implements View func (d *DiffView) StatusLine() string { - return d.left.GetName() + " vs " + d.right.GetName() + " • ↑/↓:scroll • esc:back" + return d.left.GetName() + " vs " + d.right.GetName() + " • ↑/↓:scroll • q/esc:back" } // renderSideBySide generates the side-by-side view diff --git a/internal/view/profile_detail_view.go b/internal/view/profile_detail_view.go index 1edfbe18..a53fac6d 100644 --- a/internal/view/profile_detail_view.go +++ b/internal/view/profile_detail_view.go @@ -32,7 +32,7 @@ func (v *ProfileDetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { - case "esc", "d", "q": + case "d": return v, func() tea.Msg { return HideModalMsg{} } } }