From 9d6a90394e3bd11c9597ad1281b57b3631335002 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Tue, 6 Jan 2026 15:14:36 +0000 Subject: [PATCH 1/5] docs: remove --cask flag (auto-detected) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 24a14e1..81038d5 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ A terminal UI for AWS resource management ### Homebrew (macOS/Linux) ```bash -brew install --cask clawscli/tap/claws +brew install clawscli/tap/claws ``` ### Install Script (macOS/Linux) From 737447f81e66b4923119b52e7d62c91e775c30a8 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Tue, 6 Jan 2026 15:17:11 +0000 Subject: [PATCH 2/5] docs: restore --cask flag (required for disambiguation) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 81038d5..24a14e1 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ A terminal UI for AWS resource management ### Homebrew (macOS/Linux) ```bash -brew install clawscli/tap/claws +brew install --cask clawscli/tap/claws ``` ### Install Script (macOS/Linux) From 0ecd0a58e9f883dcdbbee4dad036a18f9fa09e71 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 12:53:43 +0000 Subject: [PATCH 3/5] feat: configurable startup view with navigation improvements (#117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change default startup view from Dashboard to ServiceBrowser - Add config.startup.view support (dashboard/services/service/resource) - Add :clear-history command to clear navigation stack - Add config.navigation.max_stack_size (default: 100) - Refactor: deduplicate service parsing into registry.ParseServiceResource() - Fix navigation semantics: preserve stack on home/services navigation - Fix: ~ key now works immediately on startup (before services load) - Add ~ toggle between Dashboard ↔ ServiceBrowser from both views 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- cmd/claws/main.go | 36 +++------- docs/keybindings.md | 6 +- internal/app/app.go | 56 ++++++++++++++-- internal/config/file.go | 31 +++++++++ internal/registry/registry.go | 38 +++++++++++ internal/registry/registry_test.go | 95 +++++++++++++++++++++++++++ internal/view/command_input.go | 85 ++++++++++++++---------- internal/view/command_input_test.go | 88 ++++++++++++++++++++++++- internal/view/dashboard_view_input.go | 6 ++ internal/view/help_view.go | 11 ++-- internal/view/service_browser.go | 31 +++++---- internal/view/table_style.go | 4 +- internal/view/tag_search_view.go | 2 +- internal/view/view.go | 3 + 14 files changed, 399 insertions(+), 93 deletions(-) diff --git a/cmd/claws/main.go b/cmd/claws/main.go index 4899e8b..56a3a5c 100644 --- a/cmd/claws/main.go +++ b/cmd/claws/main.go @@ -208,6 +208,7 @@ func printUsage() { fmt.Println(" AWS region(s) to use (comma-separated or repeated)") fmt.Println(" -s, --service [/]") fmt.Println(" Start directly on a service/resource (e.g., ec2, rds/snapshots, cfn)") + fmt.Println(" Special views: dashboard, services") fmt.Println(" Supports aliases: cfn, sg, logs, ddb, etc.") fmt.Println(" -i, --resource-id ") fmt.Println(" Open detail view for a specific resource (requires --service)") @@ -230,6 +231,9 @@ func printUsage() { fmt.Println(" Show this help message") fmt.Println() fmt.Println("Examples:") + fmt.Println(" claws Start with service browser (default)") + fmt.Println(" claws -s dashboard Start with dashboard") + fmt.Println(" claws -s services Start with service browser") fmt.Println(" claws -s ec2 Open EC2 instances browser") fmt.Println(" claws -s rds/snapshots Open RDS snapshots browser") fmt.Println(" claws -s cfn Open CloudFormation stacks (alias)") @@ -270,36 +274,14 @@ func applyStartupConfig(opts cliOptions, fileCfg *config.FileConfig, cfg *config // resolveStartupService validates and resolves a service string (e.g., "ec2", "rds/snapshots", "cfn") // to a valid service/resourceType pair. Supports aliases and service/resource syntax. +// Special views "dashboard" and "services" are returned as-is. func resolveStartupService(input string) (service, resourceType string, err error) { - parts := strings.SplitN(input, "/", 2) - service = parts[0] - if len(parts) > 1 { - resourceType = parts[1] + // Special views: dashboard and services + if input == "dashboard" || input == "services" { + return input, "", nil } - if strings.Contains(resourceType, "/") { - return "", "", fmt.Errorf("invalid resource type: %s", resourceType) - } - - if resolved, resolvedRes, ok := registry.Global.ResolveAlias(service); ok { - service = resolved - if resolvedRes != "" && resourceType == "" { - resourceType = resolvedRes - } - } - - if resourceType == "" { - resourceType = registry.Global.DefaultResource(service) - if resourceType == "" { - return "", "", fmt.Errorf("unknown service: %s", input) - } - } - - if _, ok := registry.Global.Get(service, resourceType); !ok { - return "", "", fmt.Errorf("unknown resource: %s/%s", service, resourceType) - } - - return service, resourceType, nil + return registry.Global.ParseServiceResource(input) } // propagateAllProxy copies ALL_PROXY to HTTP_PROXY/HTTPS_PROXY if not set. diff --git a/docs/keybindings.md b/docs/keybindings.md index a456676..406e74c 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -17,8 +17,9 @@ Complete reference for all keyboard shortcuts in claws. | Key | Action | |-----|--------| | `:` | Command mode (e.g., `:ec2/instances`) | -| `:` + `Enter` | Go to dashboard (home) | -| `~` | Go to dashboard (from service browser) | +| `:` + `Enter` | Go to services | +| `~` | Toggle Dashboard ↔ Services | +| `:pulse` | Go to dashboard | | `:services` | Go to service browser | | `/` | Filter mode (fuzzy search) | | `?` | Show help | @@ -61,6 +62,7 @@ Complete reference for all keyboard shortcuts in claws. | `:diff ` | Compare two named resources | | `:theme ` | Change color theme | | `:autosave on/off` | Enable/disable config autosave | +| `:clear-history` | Clear navigation history (stack) | ## Mouse Support diff --git a/internal/app/app.go b/internal/app/app.go index 6b6c457..b68ec46 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -133,12 +133,23 @@ func (a *App) Init() tea.Cmd { a.awsInitializing = true if a.startupPath != nil { - a.currentView = view.NewResourceBrowserWithType( - a.ctx, a.registry, - a.startupPath.Service, a.startupPath.ResourceType, - ) + // CLI `-s` option takes precedence + switch a.startupPath.Service { + case "dashboard": + a.currentView = view.NewDashboardView(a.ctx, a.registry) + case "services": + a.currentView = view.NewServiceBrowser(a.ctx, a.registry) + default: + // Regular AWS resource browser + a.currentView = view.NewResourceBrowserWithType( + a.ctx, a.registry, + a.startupPath.Service, a.startupPath.ResourceType, + ) + } } else { - a.currentView = view.NewDashboardView(a.ctx, a.registry) + // Check config startup.view + startupView := config.File().GetStartupView() + a.currentView = a.resolveStartupView(startupView) } initAWSCmd := func() tea.Msg { @@ -335,6 +346,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case view.NavigateMsg: return a.handleNavigate(msg) + case view.ClearHistoryMsg: + log.Debug("clearing navigation history", "stackDepth", len(a.viewStack)) + a.viewStack = nil + return a, nil + case view.ErrorMsg: log.Error("application error", "error", msg.Err) a.err = msg.Err @@ -523,7 +539,7 @@ func (a *App) View() tea.View { if contentHeight < 1 { contentHeight = 1 } - paddedContent := lipgloss.NewStyle().Height(contentHeight).Render(content) + paddedContent := ui.NoStyle().Height(contentHeight).Render(content) mainView := paddedContent + "\n" + status if a.modal != nil { @@ -667,11 +683,19 @@ func (a *App) navigateBack() tea.Cmd { // pushOrClearStack either clears the view stack (for home navigation) or // pushes the current view onto the stack (for drill-down navigation). +// Enforces max stack size from config. func (a *App) pushOrClearStack(clearStack bool) { if clearStack { a.viewStack = nil } else if a.currentView != nil { a.viewStack = append(a.viewStack, a.currentView) + + // Enforce max stack size + maxSize := config.File().MaxStackSize() + if len(a.viewStack) > maxSize { + // Remove oldest entries + a.viewStack = a.viewStack[len(a.viewStack)-maxSize:] + } } } @@ -817,3 +841,23 @@ func (k keyMap) FullHelp() [][]key.Binding { {k.Filter, k.Command, k.Help, k.Quit}, } } + +// resolveStartupView resolves the startup view from config. +// Returns ServiceBrowser by default if config is empty or invalid. +func (a *App) resolveStartupView(viewName string) view.View { + switch viewName { + case "dashboard": + return view.NewDashboardView(a.ctx, a.registry) + case "services", "": + // Default to ServiceBrowser + return view.NewServiceBrowser(a.ctx, a.registry) + default: + // Try to parse as AWS service/resource (e.g., "ec2", "rds/snapshots") + service, resourceType, err := a.registry.ParseServiceResource(viewName) + if err != nil { + // Fallback to ServiceBrowser on error + return view.NewServiceBrowser(a.ctx, a.registry) + } + return view.NewResourceBrowserWithType(a.ctx, a.registry, service, resourceType) + } +} diff --git a/internal/config/file.go b/internal/config/file.go index 977b320..d2ca68d 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -20,6 +20,7 @@ const ( DefaultLogFetchTimeout = 10 * time.Second DefaultMetricsWindow = 15 * time.Minute DefaultMaxConcurrentFetches = 50 + DefaultMaxStackSize = 100 ) func ConfigDir() (string, error) { @@ -59,6 +60,7 @@ type PersistenceConfig struct { } type StartupConfig struct { + View string `yaml:"view,omitempty"` // "dashboard", "services", or "service/resource" (e.g., "ec2", "rds/snapshots") Regions []string `yaml:"regions,omitempty"` Profile string `yaml:"profile,omitempty"` // Deprecated: for backward compat (read-only) Profiles []string `yaml:"profiles,omitempty"` // New format: multiple profile IDs @@ -76,6 +78,11 @@ func (s StartupConfig) GetProfiles() []string { return nil } +// NavigationConfig controls navigation behavior. +type NavigationConfig struct { + MaxStackSize int `yaml:"max_stack_size,omitempty"` +} + // ThemeConfig holds theme configuration. // Can be specified as: // - A preset name string: "dark", "light", "nord", "dracula", "gruvbox", "catppuccin" @@ -126,6 +133,7 @@ type FileConfig struct { Autosave PersistenceConfig `yaml:"autosave,omitempty"` Startup StartupConfig `yaml:"startup,omitempty"` Theme ThemeConfig `yaml:"theme,omitempty"` + Navigation NavigationConfig `yaml:"navigation,omitempty"` } // Duration wraps time.Duration for YAML marshal/unmarshal as string (e.g., "5s", "30s") @@ -171,6 +179,9 @@ func DefaultFileConfig() *FileConfig { CloudWatch: CloudWatchConfig{ Window: Duration(DefaultMetricsWindow), }, + Navigation: NavigationConfig{ + MaxStackSize: DefaultMaxStackSize, + }, } } @@ -235,6 +246,9 @@ func (c *FileConfig) applyDefaults() { if c.Concurrency.MaxFetches <= 0 { c.Concurrency.MaxFetches = DefaultMaxConcurrentFetches } + if c.Navigation.MaxStackSize <= 0 { + c.Navigation.MaxStackSize = DefaultMaxStackSize + } } func (c *FileConfig) AWSInitTimeout() time.Duration { @@ -300,6 +314,16 @@ func (c *FileConfig) MetricsWindow() time.Duration { }) } +// MaxStackSize returns the maximum navigation stack size. +func (c *FileConfig) MaxStackSize() int { + return withRLock(&c.mu, func() int { + if c.Navigation.MaxStackSize <= 0 { + return DefaultMaxStackSize + } + return c.Navigation.MaxStackSize + }) +} + func (c *FileConfig) PersistenceEnabled() bool { return withRLock(&c.mu, func() bool { if c.persistenceOverride != nil { @@ -327,6 +351,13 @@ func (c *FileConfig) GetStartup() ([]string, []string) { return r.regions, r.profiles } +// GetStartupView returns the configured startup view ("dashboard", "services", or service/resource). +func (c *FileConfig) GetStartupView() string { + return withRLock(&c.mu, func() string { + return c.Startup.View + }) +} + func (c *FileConfig) GetTheme() ThemeConfig { return withRLock(&c.mu, func() ThemeConfig { return c.Theme }) } diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 21ae2a7..15704c3 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -295,6 +295,44 @@ func (r *Registry) ResolveAlias(input string) (string, string, bool) { return input, "", false } +// ParseServiceResource parses a service/resource string (e.g., "ec2", "rds/snapshots", "cfn") +// and resolves it to a valid service/resourceType pair. +// Returns (service, resourceType, error) +func (r *Registry) ParseServiceResource(input string) (service, resourceType string, err error) { + parts := strings.SplitN(input, "/", 2) + service = parts[0] + if len(parts) > 1 { + resourceType = parts[1] + } + + if strings.Contains(resourceType, "/") { + return "", "", fmt.Errorf("invalid resource type: %s", resourceType) + } + + // Try alias resolution + if resolved, resolvedRes, ok := r.ResolveAlias(service); ok { + service = resolved + if resolvedRes != "" && resourceType == "" { + resourceType = resolvedRes + } + } + + // Get default resource if not specified + if resourceType == "" { + resourceType = r.DefaultResource(service) + if resourceType == "" { + return "", "", fmt.Errorf("unknown service: %s", input) + } + } + + // Validate service/resource exists + if _, ok := r.Get(service, resourceType); !ok { + return "", "", fmt.Errorf("unknown resource: %s/%s", service, resourceType) + } + + return service, resourceType, nil +} + // GetAliasesForService returns all aliases for a given service. func (r *Registry) GetAliasesForService(service string) []string { r.serviceAliasesOnce.Do(func() { diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 203d3ec..68d62d6 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -540,3 +540,98 @@ func TestRegistry_ListServicesByCategory(t *testing.T) { t.Error("ListServicesByCategory() should include Compute category") } } + +func TestRegistry_ParseServiceResource(t *testing.T) { + reg := New() + + // Register test services + reg.RegisterCustom("ec2", "instances", Entry{}) + reg.RegisterCustom("ec2", "volumes", Entry{}) + reg.RegisterCustom("rds", "instances", Entry{}) + reg.RegisterCustom("rds", "snapshots", Entry{}) + reg.RegisterCustom("s3", "buckets", Entry{}) + reg.RegisterCustom("cloudformation", "stacks", Entry{}) + + tests := []struct { + name string + input string + wantService string + wantResource string + wantErr bool + }{ + { + name: "service only - use default resource", + input: "ec2", + wantService: "ec2", + wantResource: "instances", + wantErr: false, + }, + { + name: "service/resource explicit", + input: "rds/snapshots", + wantService: "rds", + wantResource: "snapshots", + wantErr: false, + }, + { + name: "alias to service - use default", + input: "cfn", + wantService: "cloudformation", + wantResource: "stacks", + wantErr: false, + }, + { + name: "alias to service/resource", + input: "sg", + wantService: "ec2", + wantResource: "security-groups", + wantErr: true, // security-groups not registered in this test + }, + { + name: "unknown service", + input: "unknown-service", + wantService: "", + wantResource: "", + wantErr: true, + }, + { + name: "invalid resource type with multiple slashes", + input: "ec2/instances/extra", + wantService: "", + wantResource: "", + wantErr: true, + }, + { + name: "unknown resource for valid service", + input: "ec2/invalid-resource", + wantService: "", + wantResource: "", + wantErr: true, + }, + { + name: "s3 service - default to buckets", + input: "s3", + wantService: "s3", + wantResource: "buckets", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service, resource, err := reg.ParseServiceResource(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseServiceResource(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if err == nil { + if service != tt.wantService { + t.Errorf("ParseServiceResource(%q) service = %q, want %q", tt.input, service, tt.wantService) + } + if resource != tt.wantResource { + t.Errorf("ParseServiceResource(%q) resource = %q, want %q", tt.input, resource, tt.wantResource) + } + } + }) + } +} diff --git a/internal/view/command_input.go b/internal/view/command_input.go index 6b3f472..5a12895 100644 --- a/internal/view/command_input.go +++ b/internal/view/command_input.go @@ -213,10 +213,16 @@ func (c *CommandInput) SetDiffProvider(provider DiffCompletionProvider) { func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { input := strings.TrimSpace(c.textInput.Value()) - // Empty input or home/pulse - go to dashboard - if input == "" || input == "home" || input == "pulse" { + // Empty input or home - go to service browser (new default home) + if input == "" || input == "home" { + browser := NewServiceBrowser(c.ctx, c.registry) + return nil, &NavigateMsg{View: browser, ClearStack: false} + } + + // Handle pulse command - go to dashboard + if input == "pulse" { dashboard := NewDashboardView(c.ctx, c.registry) - return nil, &NavigateMsg{View: dashboard, ClearStack: true} + return nil, &NavigateMsg{View: dashboard, ClearStack: false} } // Handle quit command @@ -224,10 +230,23 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { return tea.Quit, nil } + // Handle clear-history command - clear navigation stack + if input == "clear-history" { + return func() tea.Msg { + return ClearHistoryMsg{} + }, nil + } + + // Handle dashboard command - explicitly open dashboard + if input == "dashboard" { + dashboard := NewDashboardView(c.ctx, c.registry) + return nil, &NavigateMsg{View: dashboard, ClearStack: false} + } + // Handle services/browse command - go to service browser if input == "services" || input == "browse" { browser := NewServiceBrowser(c.ctx, c.registry) - return nil, &NavigateMsg{View: browser, ClearStack: true} + return nil, &NavigateMsg{View: browser, ClearStack: false} } // Handle sort command: :sort (clear) or :sort (sort by column) @@ -315,43 +334,37 @@ func (c *CommandInput) executeCommand() (tea.Cmd, *NavigateMsg) { } } - // Parse command: service or service/resource - parts := strings.SplitN(input, "/", 2) - service := parts[0] - resourceType := "" + // Try ParseServiceResource first (handles aliases, defaults, validation) + service, resourceType, err := c.registry.ParseServiceResource(input) + if err == nil { + browser := NewResourceBrowserWithType(c.ctx, c.registry, service, resourceType) + return nil, &NavigateMsg{View: browser} + } + // Fallback: prefix matching for partial input + parts := strings.SplitN(input, "/", 2) + service = parts[0] + resourceType = "" if len(parts) > 1 { resourceType = parts[1] } - // Try to resolve alias first (e.g., "cfn" -> "cloudformation") - if resolvedService, resolvedResource, ok := c.registry.ResolveAlias(service); ok { - service = resolvedService - if resolvedResource != "" && resourceType == "" { - resourceType = resolvedResource - } - } - - if resourceType == "" { - resourceType = c.registry.DefaultResource(service) - } - - if _, ok := c.registry.Get(service, resourceType); !ok { - for _, svc := range c.registry.ListServices() { - if strings.HasPrefix(svc, service) { - service = svc - if resourceType == "" { - resourceType = c.registry.DefaultResource(svc) - } else { - for _, res := range c.registry.ListResources(svc) { - if strings.HasPrefix(res, resourceType) { - resourceType = res - break - } + // Try prefix match on service + for _, svc := range c.registry.ListServices() { + if strings.HasPrefix(svc, service) { + service = svc + if resourceType == "" { + resourceType = c.registry.DefaultResource(svc) + } else { + // Prefix match on resource + for _, res := range c.registry.ListResources(svc) { + if strings.HasPrefix(res, resourceType) { + resourceType = res + break } } - break } + break } } @@ -449,9 +462,15 @@ func (c *CommandInput) GetSuggestions() []string { if strings.HasPrefix("services", input) { suggestions = append(suggestions, "services") } + if strings.HasPrefix("dashboard", input) { + suggestions = append(suggestions, "dashboard") + } if strings.HasPrefix("login", input) { suggestions = append(suggestions, "login") } + if strings.HasPrefix("clear-history", input) { + suggestions = append(suggestions, "clear-history") + } // Add "tag" command (current view filter) if strings.HasPrefix("tag", input) && !strings.HasPrefix("tags", input) { diff --git a/internal/view/command_input_test.go b/internal/view/command_input_test.go index 36b329d..49b23b5 100644 --- a/internal/view/command_input_test.go +++ b/internal/view/command_input_test.go @@ -172,8 +172,8 @@ func TestCommandInput_Update_Enter_Empty(t *testing.T) { if nav == nil { t.Error("Expected NavigateMsg for empty enter") } - if nav != nil && !nav.ClearStack { - t.Error("Expected ClearStack=true for home navigation") + if nav != nil && nav.ClearStack { + t.Error("Expected ClearStack=false (preserves navigation stack)") } } @@ -373,3 +373,87 @@ func TestCommandInput_getDiffSuggestions(t *testing.T) { }) } } + +func TestCommandInput_ClearHistoryCommand(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + ci := NewCommandInput(ctx, reg) + ci.Activate() + ci.textInput.SetValue("clear-history") + + cmd, nav := ci.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + // Should return a command (not quit) + if cmd == nil { + t.Error("Expected command for clear-history") + } + + // Should not return NavigateMsg + if nav != nil { + t.Error("Expected nil NavigateMsg for clear-history") + } + + // Execute the command to verify it returns ClearHistoryMsg + if cmd != nil { + msg := cmd() + if _, ok := msg.(ClearHistoryMsg); !ok { + t.Errorf("Expected ClearHistoryMsg, got %T", msg) + } + } +} + +func TestCommandInput_DashboardCommand(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + tests := []struct { + input string + wantNavigate bool + wantClearStack bool + }{ + {"pulse", true, false}, + {"dashboard", true, false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + ci := NewCommandInput(ctx, reg) + ci.Activate() + ci.textInput.SetValue(tt.input) + + _, nav := ci.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if tt.wantNavigate && nav == nil { + t.Errorf("Expected NavigateMsg for %q", tt.input) + } + if nav != nil && nav.ClearStack != tt.wantClearStack { + t.Errorf("%q: ClearStack = %v, want %v", tt.input, nav.ClearStack, tt.wantClearStack) + } + }) + } +} + +func TestCommandInput_ServicesCommand(t *testing.T) { + ctx := context.Background() + reg := registry.New() + + tests := []string{"services", "browse", "home", ""} + + for _, input := range tests { + t.Run("input="+input, func(t *testing.T) { + ci := NewCommandInput(ctx, reg) + ci.Activate() + ci.textInput.SetValue(input) + + _, nav := ci.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + if nav == nil { + t.Errorf("Expected NavigateMsg for %q", input) + } + if nav != nil && nav.ClearStack { + t.Errorf("%q: ClearStack = true, want false (preserves stack)", input) + } + }) + } +} diff --git a/internal/view/dashboard_view_input.go b/internal/view/dashboard_view_input.go index c9e9954..f342442 100644 --- a/internal/view/dashboard_view_input.go +++ b/internal/view/dashboard_view_input.go @@ -6,6 +6,12 @@ import ( func (d *DashboardView) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { + case "~": + // Toggle to ServiceBrowser + browser := NewServiceBrowser(d.ctx, d.registry) + return d, func() tea.Msg { + return NavigateMsg{View: browser, ClearStack: false} + } case "s": browser := NewServiceBrowser(d.ctx, d.registry) return d, func() tea.Msg { diff --git a/internal/view/help_view.go b/internal/view/help_view.go index 710838d..e728ff0 100644 --- a/internal/view/help_view.go +++ b/internal/view/help_view.go @@ -74,7 +74,7 @@ func (h *HelpView) renderContent() string { out += "\n" + s.section.Render("Service Browser") + "\n" out += s.key.Render("←/h, →/l") + s.desc.Render("Move within category") + "\n" out += s.key.Render("↑/k, ↓/j") + s.desc.Render("Move between categories") + "\n" - out += s.key.Render("~") + s.desc.Render("Go to dashboard (home)") + "\n" + out += s.key.Render("~") + s.desc.Render("Toggle Dashboard ↔ Services") + "\n" out += s.key.Render("/") + s.desc.Render("Filter services") + "\n" // Resource Browser @@ -96,9 +96,12 @@ func (h *HelpView) renderContent() string { // Command Mode out += "\n" + s.section.Render("Command Mode") + "\n" out += s.key.Render(":") + s.desc.Render("Enter command mode") + "\n" - out += s.key.Render(": + Enter") + s.desc.Render("Go to dashboard (home)") + "\n" - out += s.key.Render(":home") + s.desc.Render("Go to dashboard") + "\n" - out += s.key.Render(":services") + s.desc.Render("Browse services") + "\n" + out += s.key.Render(": + Enter") + s.desc.Render("Go to services") + "\n" + out += s.key.Render(":home") + s.desc.Render("Go to services") + "\n" + out += s.key.Render(":pulse") + s.desc.Render("Go to dashboard") + "\n" + out += s.key.Render(":dashboard") + s.desc.Render("Go to dashboard") + "\n" + out += s.key.Render(":services") + s.desc.Render("Go to services") + "\n" + out += s.key.Render(":clear-history") + s.desc.Render("Clear navigation history") + "\n" out += s.key.Render("Tab") + s.desc.Render("Cycle through suggestions") + "\n" out += s.key.Render("Shift+Tab") + s.desc.Render("Cycle backward") + "\n" out += s.key.Render("Enter") + s.desc.Render("Execute command") + "\n" diff --git a/internal/view/service_browser.go b/internal/view/service_browser.go index 8f63a2a..0b86c1f 100644 --- a/internal/view/service_browser.go +++ b/internal/view/service_browser.go @@ -267,12 +267,22 @@ func (s *ServiceBrowser) handleFilterInput(msg tea.KeyPressMsg) (tea.Model, tea. } func (s *ServiceBrowser) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - if len(s.flatItems) == 0 { - if msg.String() == "/" { - s.filterActive = true - s.filterInput.Focus() - return s, textinput.Blink + // Handle special keys that work regardless of flatItems state + switch msg.String() { + case "~": + // Toggle to Dashboard + dashboard := NewDashboardView(s.ctx, s.registry) + return s, func() tea.Msg { + return NavigateMsg{View: dashboard, ClearStack: false} } + case "/": + s.filterActive = true + s.filterInput.Focus() + return s, textinput.Blink + } + + // Navigation requires loaded services + if len(s.flatItems) == 0 { return s, nil } @@ -306,11 +316,6 @@ func (s *ServiceBrowser) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.C case "enter": return s.selectCurrentService() - case "/": - s.filterActive = true - s.filterInput.Focus() - return s, textinput.Blink - case "c": if s.filterText != "" { s.filterText = "" @@ -318,12 +323,6 @@ func (s *ServiceBrowser) handleNavigation(msg tea.KeyPressMsg) (tea.Model, tea.C s.rebuildFlatItems() s.cursor = 0 } - - case "~": - dashboard := NewDashboardView(s.ctx, s.registry) - return s, func() tea.Msg { - return NavigateMsg{View: dashboard, ClearStack: true} - } } // Also allow esc to clear filter (handles various escape sequences) diff --git a/internal/view/table_style.go b/internal/view/table_style.go index 5ec6fdf..91b27ab 100644 --- a/internal/view/table_style.go +++ b/internal/view/table_style.go @@ -20,7 +20,7 @@ func NewTableStyleFunc(widths []int, cursor int) func(row, col int) lipgloss.Sty normalStyles := make([]lipgloss.Style, numCols) for col, w := range widths { - base := lipgloss.NewStyle().Width(w) + base := ui.NoStyle().Width(w) if col == 0 { base = base.PaddingLeft(1) } @@ -46,5 +46,5 @@ func NewTableStyleFunc(widths []int, cursor int) func(row, col int) lipgloss.Sty // TableBorderStyle returns a style for table borders using the current theme. func TableBorderStyle() lipgloss.Style { - return lipgloss.NewStyle().Foreground(ui.Current().TableBorder) + return ui.BorderStyle() } diff --git a/internal/view/tag_search_view.go b/internal/view/tag_search_view.go index 45f1540..2b0d35c 100644 --- a/internal/view/tag_search_view.go +++ b/internal/view/tag_search_view.go @@ -44,7 +44,7 @@ func newTagSearchViewStyles() tagSearchViewStyles { return tagSearchViewStyles{ header: ui.TableHeaderStyle().Padding(0, 1), status: ui.DimStyle().Padding(0, 1), - filterWrap: lipgloss.NewStyle().Padding(0, 1), + filterWrap: ui.NoStyle().Padding(0, 1), filterActive: ui.AccentStyle().Italic(true), } } diff --git a/internal/view/view.go b/internal/view/view.go index 6110524..2a7bd99 100644 --- a/internal/view/view.go +++ b/internal/view/view.go @@ -93,6 +93,9 @@ type DiffMsg struct { RightName string // Name of right resource } +// ClearHistoryMsg tells the app to clear the navigation stack +type ClearHistoryMsg struct{} + // Refreshable is an interface for views that can refresh their data // Views like ResourceBrowser implement this, while DetailView does not type Refreshable interface { From f4f8f3f461ad16715f1a2c49dfcb9f95b5232d9b Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 13:06:01 +0000 Subject: [PATCH 4/5] refactor: consolidate view resolution logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicated switch in Init(), delegate to resolveStartupView() for all startup paths (CLI and config). - Eliminates code duplication - Single source of truth for view creation - Maintains existing behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- internal/app/app.go | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index b68ec46..67cc2a8 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -134,18 +134,11 @@ func (a *App) Init() tea.Cmd { if a.startupPath != nil { // CLI `-s` option takes precedence - switch a.startupPath.Service { - case "dashboard": - a.currentView = view.NewDashboardView(a.ctx, a.registry) - case "services": - a.currentView = view.NewServiceBrowser(a.ctx, a.registry) - default: - // Regular AWS resource browser - a.currentView = view.NewResourceBrowserWithType( - a.ctx, a.registry, - a.startupPath.Service, a.startupPath.ResourceType, - ) + viewName := a.startupPath.Service + if a.startupPath.ResourceType != "" { + viewName = fmt.Sprintf("%s/%s", a.startupPath.Service, a.startupPath.ResourceType) } + a.currentView = a.resolveStartupView(viewName) } else { // Check config startup.view startupView := config.File().GetStartupView() From 499b713bb7eac8a72cf5225a67a8c033d4438b14 Mon Sep 17 00:00:00 2001 From: "m@yim.jp" Date: Wed, 7 Jan 2026 13:47:31 +0000 Subject: [PATCH 5/5] docs: add startup.view and navigation config examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add startup.view option to config example (dashboard/services/service/resource) - Add navigation.max_stack_size config option - Add -s dashboard/services examples to README 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- README.md | 4 +++- docs/configuration.md | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 24a14e1..833bd8d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,9 @@ claws -p myprofile # With specific region claws -r us-west-2 -# Start directly on a service +# Start directly on a service or view +claws -s dashboard # Start with dashboard +claws -s services # Start with service browser (default) claws -s ec2 # EC2 instances claws -s rds/snapshots # RDS snapshots diff --git a/docs/configuration.md b/docs/configuration.md index 3b65e8c..521b8bf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,12 +30,16 @@ autosave: enabled: true # Save region/profile/theme on change (default: false) startup: # Applied on launch if present + view: services # Startup view: "dashboard", "services", or "service/resource" (e.g., "ec2", "rds/snapshots") profiles: # Multiple profiles supported - production regions: - us-east-1 - us-west-2 +navigation: + max_stack_size: 100 # Max navigation history depth (default: 100) + theme: nord # Preset: dark, light, nord, dracula, gruvbox, catppuccin # Or use preset with custom overrides: