From 5eba8a3fa942b4daa8e668257d0d4a480b9dfab3 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 31 Dec 2025 09:43:41 +0000 Subject: [PATCH 1/7] fix: keyboard handling on warning screen and add q to quit - Fix race condition where warningsReady was never set if WindowSizeMsg arrived before AWS init failed - Add 'q' key to quit the app (was documented in help but not implemented) Fixes #60 --- internal/app/app.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 6c952996..bfe464a4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -163,7 +163,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // 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 { + if !a.warningsReady { a.warningsReady = true } if a.currentView != nil { @@ -533,8 +533,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"), ), } } From cac0f2bb4f46d04ec4e39db24e236fa85c4c52f9 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 31 Dec 2025 10:00:13 +0000 Subject: [PATCH 2/7] refactor: clean up q key handling and add :q command - Remove dead code: q key handler in DiffView (now handled by app) - Remove q from ProfileDetailView modal close (consistency with help) - Add :q/:quit command for vim-style quit - Update README with q key and :q command docs --- README.md | 3 ++- internal/view/command_input.go | 8 ++++++++ internal/view/diff_view.go | 2 +- internal/view/profile_detail_view.go | 4 ++-- 4 files changed, 13 insertions(+), 4 deletions(-) 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/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/diff_view.go b/internal/view/diff_view.go index bceb23eb..c7842596 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -68,7 +68,7 @@ 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" { + if IsEscKey(msg) { return d, nil // Let app handle back navigation } } diff --git a/internal/view/profile_detail_view.go b/internal/view/profile_detail_view.go index 1edfbe18..f4bb1393 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 "esc", "d": return v, func() tea.Msg { return HideModalMsg{} } } } @@ -52,7 +52,7 @@ func (v *ProfileDetailView) SetSize(_, _ int) tea.Cmd { } func (v *ProfileDetailView) StatusLine() string { - return "Esc/d/q:close" + return "Esc/d:close" } func (v *ProfileDetailView) buildContent() string { From 8bf39f1d697c9ce60993d7aa23455d2dcd968059 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 31 Dec 2025 10:12:07 +0000 Subject: [PATCH 3/7] fix: keep q key to close ProfileDetailView modal Modals should intercept q to close rather than letting it quit the app. This prevents accidental app quit while viewing profile details. --- internal/view/profile_detail_view.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/view/profile_detail_view.go b/internal/view/profile_detail_view.go index f4bb1393..1edfbe18 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": + case "esc", "d", "q": return v, func() tea.Msg { return HideModalMsg{} } } } @@ -52,7 +52,7 @@ func (v *ProfileDetailView) SetSize(_, _ int) tea.Cmd { } func (v *ProfileDetailView) StatusLine() string { - return "Esc/d:close" + return "Esc/d/q:close" } func (v *ProfileDetailView) buildContent() string { From fc56a04c63e73ae94b001368d903f6134211132c Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 31 Dec 2025 10:24:30 +0000 Subject: [PATCH 4/7] fix: unify q key handling for modals - Add q key to close modals in handleModalUpdate (centralized) - Remove redundant esc/q handling from ProfileDetailView (keep d only) - Now all modals close consistently with q key --- internal/app/app.go | 2 +- internal/view/profile_detail_view.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index bfe464a4..4c8d8872 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -456,7 +456,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 } 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{} } } } From 79597b6041d81dfa09ee162fa673d1415e889929 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 31 Dec 2025 10:38:44 +0000 Subject: [PATCH 5/7] fix: q key for back nav in detail/diff views, space key in warning --- internal/app/app.go | 4 ++-- internal/view/detail_view.go | 4 ++-- internal/view/diff_view.go | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 4c8d8872..bec61627 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 } @@ -420,7 +420,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) diff --git a/internal/view/detail_view.go b/internal/view/detail_view.go index 6795e791..5d58d766 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -130,7 +130,7 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: // Let app handle back navigation - if IsEscKey(msg) { + if IsEscKey(msg) || msg.String() == "q" { 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 c7842596..9a98e01c 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) { - return d, nil // Let app handle back navigation + // Let app handle back navigation + if IsEscKey(msg) || msg.String() == "q" { + 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 From 1ad14abd525dddac4b5377a6dccd7ce4da9ac268 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 31 Dec 2025 10:54:31 +0000 Subject: [PATCH 6/7] fix: q key goes back in DetailView/DiffView (lazygit-style) --- internal/app/app.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/app/app.go b/internal/app/app.go index bec61627..bdd68bcd 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -203,6 +203,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): From 82bf15d33c37091045dfba9127b21a5806525bf4 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 31 Dec 2025 11:24:41 +0000 Subject: [PATCH 7/7] fix: address review feedback - remove dead code, add tests - Remove unreachable q key checks in DetailView/DiffView (app.go intercepts first) - Add TestWarningScreenDismissal for Enter/Space/q dismissal - Add TestCommandInput_QuitCommand for :q/:quit commands - Simplify warningsReady with clarifying comment --- internal/app/app.go | 7 +++---- internal/app/app_test.go | 28 +++++++++++++++++++++++++++ internal/view/command_input_test.go | 30 +++++++++++++++++++++++++++++ internal/view/detail_view.go | 4 ++-- internal/view/diff_view.go | 4 ++-- 5 files changed, 65 insertions(+), 8 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index bdd68bcd..d6c746ef 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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.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) } 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_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 5d58d766..d8a9c682 100644 --- a/internal/view/detail_view.go +++ b/internal/view/detail_view.go @@ -129,8 +129,8 @@ func (d *DetailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return d, nil case tea.KeyPressMsg: - // Let app handle back navigation - if IsEscKey(msg) || msg.String() == "q" { + // Let app handle back navigation (esc/backspace/q handled by app.go) + if IsEscKey(msg) { return d, nil } diff --git a/internal/view/diff_view.go b/internal/view/diff_view.go index 9a98e01c..7cc45e31 100644 --- a/internal/view/diff_view.go +++ b/internal/view/diff_view.go @@ -68,8 +68,8 @@ func (d *DiffView) Init() tea.Cmd { func (d *DiffView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: - // Let app handle back navigation - if IsEscKey(msg) || msg.String() == "q" { + // Let app handle back navigation (esc/backspace/q handled by app.go) + if IsEscKey(msg) { return d, nil } }