From 9d9a7ae244f03c9b76d7cd5ae9377cacbd7a4dc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 12:26:40 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20runtime=20config=20editing=20?= =?UTF-8?q?=E2=80=94=20add/save=20tabs=20with=20YAML=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "+" opens a two-step floating dialog (name → command) to add a new tab; the new TabConfig is validated against the full config before config.yaml is written, preventing broken configs - "w" on any config tab saves the current (possibly edited) command back to config.yaml immediately - "w" on the Search tab prompts for a name and saves the search command as a permanent tab entry in config.yaml - After a successful add, the new tab is appended to the tab bar and focused automatically - tabs.AddTab(), config.SaveConfig(), TabAddedMsg / ConfigSavedMsg wired into the Elm-style Update loop; disk I/O runs in a Cmd goroutine so the TUI stays responsive - Footer hints updated to advertise [w]save and [+]new tab https://claude.ai/code/session_01GYo9iNKVfzrzZafwzRr7hz --- internal/config/config.go | 12 ++ internal/tui/app.go | 225 +++++++++++++++++++++--- internal/tui/components/dialog/input.go | 1 + internal/tui/components/tabs/tabs.go | 5 + internal/tui/messages.go | 9 + main.go | 2 +- 6 files changed, 225 insertions(+), 29 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index c947eed..7233332 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -119,6 +119,18 @@ func EnsureConfigExists() (string, error) { return path, nil } +// SaveConfig writes the config to the given path as YAML after validating it. +func SaveConfig(cfg *Config, path string) error { + if err := cfg.Validate(); err != nil { + return fmt.Errorf("config validation failed: %w", err) + } + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + return os.WriteFile(path, data, 0644) +} + // Validate checks if the configuration is valid func (c *Config) Validate() error { if c.KubectlBin == "" { diff --git a/internal/tui/app.go b/internal/tui/app.go index d61c75b..0b4d09b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -106,8 +106,9 @@ type TabSortState struct { // Model is the main application model for the TUI type Model struct { - config *config.Config - kubectl *kubectl.Kubectl + config *config.Config + kubectl *kubectl.Kubectl + configPath string // UI state viewState ViewState @@ -126,9 +127,10 @@ type Model struct { input *dialog.InputModel // Pending action state - pendingAction string - pendingNames []string - pendingNs string + pendingAction string + pendingNames []string + pendingNs string + pendingTabName string // name collected during multi-step add-tab flow // Data state currentContext string @@ -160,7 +162,7 @@ type Model struct { } // NewModel creates a new application model -func NewModel(cfg *config.Config, k *kubectl.Kubectl) *Model { +func NewModel(cfg *config.Config, k *kubectl.Kubectl, configPath string) *Model { // Extract tab names from config, prepending Search tab tabNames := make([]string, len(cfg.Tabs)+1) tabNames[0] = "Search" @@ -168,11 +170,6 @@ func NewModel(cfg *config.Config, k *kubectl.Kubectl) *Model { tabNames[i+1] = tab.Name } - // Create search input for the Search tab - searchInput := dialog.NewInput("kubectl get", "pods -A") - searchInput.WithHint("e.g. pods -A, deployments -n default, services -l app=web") - searchInput.WithValue("") - // Create spinner for loading indicator s := spinner.New() s.Spinner = spinner.Dot @@ -181,6 +178,7 @@ func NewModel(cfg *config.Config, k *kubectl.Kubectl) *Model { return &Model{ config: cfg, kubectl: k, + configPath: configPath, viewState: ViewList, currentTab: 1, // Start on first configured tab, not Search header: header.New("", "", 0), @@ -188,7 +186,7 @@ func NewModel(cfg *config.Config, k *kubectl.Kubectl) *Model { list: list.New([]string{}, [][]string{}), search: search.New(), detail: detail.New("", "", detail.FormatTable), - searchInput: searchInput, + searchInput: dialog.NewInput("kubectl get", "pods -A"), tabSearchStates: make(map[int]string), tabRuntimeSorts: make(map[int]TabSortState), loading: true, @@ -363,19 +361,44 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.searchInput.Result() { case dialog.InputSubmitted: - m.viewState = ViewList - newCommand := m.searchInput.Value() + newValue := m.searchInput.Value() + actionID := m.searchInput.ActionID() m.searchInput.Reset() - if m.currentTab == SearchTabIndex { - m.searchCommand = newCommand - return m, m.startLoading(m.executeSearchCommand()) - } - // Config tab: update the tab's command and reload - configTabIndex := m.currentTab - 1 - if configTabIndex >= 0 && configTabIndex < len(m.config.Tabs) { - m.config.Tabs[configTabIndex].Command = newCommand + m.viewState = ViewList + + switch actionID { + case "add-tab-name": + m.pendingTabName = strings.TrimSpace(newValue) + if m.pendingTabName == "" { + return m, nil + } + inp := dialog.NewInput("New Tab: Command", "pods -A") + inp.WithHint(fmt.Sprintf("kubectl get command for tab %q (e.g. pods -A, deployments -n default)", m.pendingTabName)) + inp.WithActionID("add-tab-command") + inp.SetSize(m.width, m.height) + m.searchInput = inp + m.viewState = ViewSearchTab + return m, nil + + case "add-tab-command": + return m, m.addNewTab(m.pendingTabName, newValue) + + case "save-search-name": + return m, m.saveSearchAsTab(newValue) + + default: + // Original enter-key behavior: execute or update command + if m.currentTab == SearchTabIndex { + m.searchCommand = newValue + return m, m.startLoading(m.executeSearchCommand()) + } + configTabIndex := m.currentTab - 1 + if configTabIndex >= 0 && configTabIndex < len(m.config.Tabs) { + m.config.Tabs[configTabIndex].Command = newValue + } + return m, m.startLoading(m.loadResources()) } - return m, m.startLoading(m.loadResources()) + case dialog.InputCancelled: m.viewState = ViewList m.searchInput.Reset() @@ -435,16 +458,50 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "enter": // Open command input dialog pre-filled with current command + var currentCmd, hint string if m.currentTab == SearchTabIndex { - m.searchInput.SetValue(m.searchCommand) + currentCmd = m.searchCommand + hint = "e.g. pods -A, deployments -n default, services -l app=web" } else { configTabIndex := m.currentTab - 1 if configTabIndex >= 0 && configTabIndex < len(m.config.Tabs) { - m.searchInput.SetValue(m.config.Tabs[configTabIndex].Command) + currentCmd = m.config.Tabs[configTabIndex].Command } + hint = "e.g. pods -A, deployments -n default" } + inp := dialog.NewInput("kubectl get", "pods -A") + inp.WithHint(hint) + inp.SetValue(currentCmd) + inp.SetSize(m.width, m.height) + m.searchInput = inp + m.viewState = ViewSearchTab + return m, nil + + case "+": + // Open dialog to add a new tab to config + inp := dialog.NewInput("New Tab: Name", "e.g. My Pods") + inp.WithHint("Enter a name for the new tab, then press Enter") + inp.WithActionID("add-tab-name") + inp.SetSize(m.width, m.height) + m.searchInput = inp m.viewState = ViewSearchTab - m.searchInput.SetSize(m.width, m.height) + return m, nil + + case "w": + // Save current tab's command to config.yaml + if m.currentTab == SearchTabIndex { + if m.searchCommand == "" { + return m, nil + } + inp := dialog.NewInput("Save Search as Tab", "e.g. My Pods") + inp.WithHint(fmt.Sprintf("Name for: kubectl get %s", normalizeGetCommand(m.searchCommand))) + inp.WithActionID("save-search-name") + inp.SetSize(m.width, m.height) + m.searchInput = inp + m.viewState = ViewSearchTab + } else { + return m, m.saveCurrentTabToConfig() + } return m, nil case "Y": // Show detail view (YAML format) @@ -623,6 +680,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case RefreshMsg: return m, tea.Batch(m.loadContext(), m.loadResources()) + + case TabAddedMsg: + m.config.Tabs = append(m.config.Tabs, config.TabConfig{ + Name: msg.Name, + Command: msg.Command, + }) + m.tabs.AddTab(msg.Name) + newTabIndex := len(m.config.Tabs) // Search is 0, config tabs start at 1 + m.saveCurrentTabSearch() + m.tabs.SetActive(newTabIndex) + m.currentTab = newTabIndex + return m, m.startLoading(m.loadResources()) + + case ConfigSavedMsg: + // silent success + return m, nil } return m, nil @@ -702,9 +775,9 @@ func (m *Model) View() string { } else if m.search.IsFiltered() { b.WriteString("[Esc] clear filter [/] modify filter [d]escribe [L]ogs [D]elete [e]dit [x]exec") } else if m.currentTab == SearchTabIndex { - b.WriteString("[Enter] enter command [/]filter results [r]efresh [q]uit") + b.WriteString("[Enter] enter command [/]filter results [w]save as tab [+]new tab [r]efresh [q]uit") } else { - b.WriteString("[d]escribe [L]ogs [Y]aml [D]elete [e]dit [x]exec [R]estart [c]ontext [n]amespace [s]ort [/]search [r]efresh [?]help") + b.WriteString("[d]escribe [L]ogs [Y]aml [D]elete [e]dit [x]exec [R]estart [c]ontext [n]amespace [s]ort [/]search [r]efresh [w]save [+]new tab [?]help") } return b.String() @@ -1451,6 +1524,102 @@ func (m *Model) showContextSelector() tea.Cmd { } } +// addNewTab validates name+command, writes the updated config to disk, and +// returns a TabAddedMsg so the main Update() can update in-memory state. +func (m *Model) addNewTab(name, command string) tea.Cmd { + name = strings.TrimSpace(name) + command = normalizeGetCommand(command) + configPath := m.configPath + + existingTabs := make([]config.TabConfig, len(m.config.Tabs)) + copy(existingTabs, m.config.Tabs) + cfgCopy := *m.config + cfgCopy.Tabs = existingTabs + + return func() tea.Msg { + if name == "" { + return ErrorMsg{Err: fmt.Errorf("tab name cannot be empty")} + } + if command == "" { + return ErrorMsg{Err: fmt.Errorf("command cannot be empty")} + } + + cfgCopy.Tabs = append(cfgCopy.Tabs, config.TabConfig{Name: name, Command: command}) + + if err := cfgCopy.Validate(); err != nil { + return ErrorMsg{Err: fmt.Errorf("invalid config: %w", err)} + } + + if configPath != "" { + if err := config.SaveConfig(&cfgCopy, configPath); err != nil { + return ErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} + } + } + + return TabAddedMsg{Name: name, Command: command} + } +} + +// saveCurrentTabToConfig persists the current tab's command to disk. +func (m *Model) saveCurrentTabToConfig() tea.Cmd { + configTabIndex := m.currentTab - 1 + if configTabIndex < 0 || configTabIndex >= len(m.config.Tabs) { + return nil + } + configPath := m.configPath + if configPath == "" { + return nil + } + + existingTabs := make([]config.TabConfig, len(m.config.Tabs)) + copy(existingTabs, m.config.Tabs) + cfgCopy := *m.config + cfgCopy.Tabs = existingTabs + + return func() tea.Msg { + if err := config.SaveConfig(&cfgCopy, configPath); err != nil { + return ErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} + } + return ConfigSavedMsg{} + } +} + +// saveSearchAsTab adds the current Search-tab command as a new named tab and +// saves to disk. +func (m *Model) saveSearchAsTab(name string) tea.Cmd { + name = strings.TrimSpace(name) + command := normalizeGetCommand(m.searchCommand) + configPath := m.configPath + + existingTabs := make([]config.TabConfig, len(m.config.Tabs)) + copy(existingTabs, m.config.Tabs) + cfgCopy := *m.config + cfgCopy.Tabs = existingTabs + + return func() tea.Msg { + if name == "" { + return ErrorMsg{Err: fmt.Errorf("tab name cannot be empty")} + } + if command == "" { + return ErrorMsg{Err: fmt.Errorf("no command to save")} + } + + cfgCopy.Tabs = append(cfgCopy.Tabs, config.TabConfig{Name: name, Command: command}) + + if err := cfgCopy.Validate(); err != nil { + return ErrorMsg{Err: fmt.Errorf("invalid config: %w", err)} + } + + if configPath != "" { + if err := config.SaveConfig(&cfgCopy, configPath); err != nil { + return ErrorMsg{Err: fmt.Errorf("failed to save config: %w", err)} + } + } + + return TabAddedMsg{Name: name, Command: command} + } +} + // showNamespaceSelector displays a selector for available namespaces func (m *Model) showNamespaceSelector() tea.Cmd { return func() tea.Msg { diff --git a/internal/tui/components/dialog/input.go b/internal/tui/components/dialog/input.go index 3c001c8..baf17ee 100644 --- a/internal/tui/components/dialog/input.go +++ b/internal/tui/components/dialog/input.go @@ -145,6 +145,7 @@ func (m *InputModel) Reset() { m.historyIndex = -1 m.draft = "" m.suggestion = "" + m.actionID = "" } // HasSuggestion returns true when an autosuggestion is available. diff --git a/internal/tui/components/tabs/tabs.go b/internal/tui/components/tabs/tabs.go index b3a30fe..efed150 100644 --- a/internal/tui/components/tabs/tabs.go +++ b/internal/tui/components/tabs/tabs.go @@ -108,6 +108,11 @@ func (m *Model) Count() int { return len(m.tabs) } +// AddTab appends a new tab with the given name +func (m *Model) AddTab(name string) { + m.tabs = append(m.tabs, name) +} + // View renders the tab bar func (m *Model) View() string { if len(m.tabs) == 0 { diff --git a/internal/tui/messages.go b/internal/tui/messages.go index 66afead..ec6a6fe 100644 --- a/internal/tui/messages.go +++ b/internal/tui/messages.go @@ -71,6 +71,15 @@ type ExecRequestMsg struct { Container string } +// TabAddedMsg is sent when a new tab has been validated and saved to config +type TabAddedMsg struct { + Name string + Command string +} + +// ConfigSavedMsg is sent when the current config has been persisted to disk +type ConfigSavedMsg struct{} + // TickMsg is sent periodically to trigger auto-refresh type TickMsg struct{} diff --git a/main.go b/main.go index 732cd54..ae0f49b 100644 --- a/main.go +++ b/main.go @@ -79,7 +79,7 @@ func main() { k := kubectl.NewKubectl(cfg.KubectlBin) // Create and run the TUI - model := tui.NewModel(cfg, k) + model := tui.NewModel(cfg, k, configPath) p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { From 5f7b7c5cbd4627c410f988c23faf919ab6a0a105 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 15:53:28 +0000 Subject: [PATCH 2/6] fix: make [w] and [+] prominent in status bar and edit dialog - Reorder footer: [w]save and [+]new tab now appear first in the hint line on all states (normal, filtered, Search tab) so they are always visible even in narrow terminals - Add [w] to filtered-state footer (was missing) - Intercept 'w' inside the command-edit dialog (Enter key, config tab): applies the new command AND saves config.yaml in one keystroke - Show "[w] apply + save to config.yaml" in the edit dialog's key-hint line via new WithExtraHints() on InputModel - README: add Runtime Config Editing section and keyboard shortcut table https://claude.ai/code/session_01GYo9iNKVfzrzZafwzRr7hz --- README.md | 34 +++++++++++++++++++++++++ internal/tui/app.go | 23 ++++++++++++++--- internal/tui/components/dialog/input.go | 11 ++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8c9bf28..6cee011 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A terminal UI for exploring and managing Kubernetes clusters. Browse resources, - **Multi-tab resource browsing** - View Pods, Deployments, Services, and other resources in configurable tabs - **Search tab** - Execute any kubectl command interactively from the first tab - **Command-based tabs** - Configure tabs with raw kubectl commands for maximum flexibility +- **Runtime config editing** - Add new tabs or save commands to `config.yaml` without leaving the TUI - **Fuzzy search** - Filter resources in real-time with intelligent matching - **Multiple output formats** - View resources as table, YAML, or JSON - **Resource actions** - Describe, logs, delete, edit, exec, port-forward, scale, and rollout restart @@ -53,6 +54,29 @@ The first tab is always the **Search** tab, which lets you run any kubectl comma The Search tab is useful for ad-hoc queries without needing to configure a new tab. +## Runtime Config Editing + +You can add new tabs and save commands to `config.yaml` directly from the TUI — no editor required. + +### Add a new tab (`+`) + +Press `+` from any tab to open a two-step dialog: + +1. Enter a name for the new tab (e.g. `Failing Pods`) +2. Enter a `kubectl get` command (e.g. `pods -A --field-selector=status.phase!=Running`) + +The new tab is validated against the full config before `config.yaml` is written, so a bad command can never corrupt your configuration. On success the tab appears immediately and is focused. + +### Save a tab command (`w`) + +| Context | Behaviour | +|---------|-----------| +| **Config tab** (main view) | Saves the tab's current command to `config.yaml` immediately | +| **Config tab** (inside the `Enter` edit dialog) | Press `w` instead of `Enter` to apply the new command **and** save it in one step | +| **Search tab** | Opens a name prompt; saves the search command as a new permanent tab entry | + +Changes are written atomically — the config is validated before the file is overwritten. + ## Fuzzy Filter Press `/` to activate fuzzy filter mode. The filter uses fuzzy matching to filter resources in real-time as you type. @@ -128,6 +152,16 @@ Press `/` to activate fuzzy filter mode. The filter uses fuzzy matching to filte | `a` | Select all | | `A` | Deselect all | +### Config editing + +| Key | Action | +|-----|--------| +| `+` | Add a new tab (prompts for name then command) | +| `w` | Save current tab command to `config.yaml` | +| `w` *(Search tab)* | Save search command as a new named tab | +| `Enter` *(edit dialog)* | Apply new command (run only) | +| `w` *(edit dialog)* | Apply new command **and** save to `config.yaml` | + ### Other | Key | Action | diff --git a/internal/tui/app.go b/internal/tui/app.go index 0b4d09b..6f78c4c 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -356,6 +356,20 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle Search tab input mode if m.viewState == ViewSearchTab { + // Intercept "w" when editing an existing config tab command (no actionID): + // apply the typed command AND immediately save to config.yaml. + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "w" && + m.searchInput.ActionID() == "" && m.currentTab != SearchTabIndex { + newValue := m.searchInput.Value() + m.searchInput.Reset() + m.viewState = ViewList + configTabIndex := m.currentTab - 1 + if configTabIndex >= 0 && configTabIndex < len(m.config.Tabs) { + m.config.Tabs[configTabIndex].Command = normalizeGetCommand(newValue) + } + return m, tea.Batch(m.startLoading(m.loadResources()), m.saveCurrentTabToConfig()) + } + var cmd tea.Cmd m.searchInput, cmd = m.searchInput.Update(msg) @@ -472,6 +486,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { inp := dialog.NewInput("kubectl get", "pods -A") inp.WithHint(hint) inp.SetValue(currentCmd) + if m.currentTab != SearchTabIndex { + inp.WithExtraHints("[w] apply + save to config.yaml") + } inp.SetSize(m.width, m.height) m.searchInput = inp m.viewState = ViewSearchTab @@ -773,11 +790,11 @@ func (m *Model) View() string { } b.WriteString(hint) } else if m.search.IsFiltered() { - b.WriteString("[Esc] clear filter [/] modify filter [d]escribe [L]ogs [D]elete [e]dit [x]exec") + b.WriteString("[w]save [+]new tab [Esc] clear filter [/] modify filter [d]escribe [L]ogs [D]elete [e]dit [x]exec") } else if m.currentTab == SearchTabIndex { - b.WriteString("[Enter] enter command [/]filter results [w]save as tab [+]new tab [r]efresh [q]uit") + b.WriteString("[Enter] enter command [w]save as tab [+]new tab [/]filter [r]efresh [q]uit") } else { - b.WriteString("[d]escribe [L]ogs [Y]aml [D]elete [e]dit [x]exec [R]estart [c]ontext [n]amespace [s]ort [/]search [r]efresh [w]save [+]new tab [?]help") + b.WriteString("[w]save [+]new tab [d]escribe [L]ogs [Y]aml [D]elete [e]dit [x]exec [R]estart [c]ontext [n]amespace [s]ort [/]search [r]efresh [?]help") } return b.String() diff --git a/internal/tui/components/dialog/input.go b/internal/tui/components/dialog/input.go index baf17ee..c3bc0ac 100644 --- a/internal/tui/components/dialog/input.go +++ b/internal/tui/components/dialog/input.go @@ -23,6 +23,7 @@ type InputModel struct { title string placeholder string hint string + extraHints string // appended to the key-hint line at the bottom textInput textinput.Model result InputResult width int @@ -95,6 +96,12 @@ func (m *InputModel) WithActionID(id string) *InputModel { return m } +// WithExtraHints appends extra key hints to the bottom hint line of the dialog. +func (m *InputModel) WithExtraHints(hints string) *InputModel { + m.extraHints = hints + return m +} + // WithValue sets the initial value (builder pattern) func (m *InputModel) WithValue(value string) *InputModel { m.textInput.SetValue(value) @@ -146,6 +153,7 @@ func (m *InputModel) Reset() { m.draft = "" m.suggestion = "" m.actionID = "" + m.extraHints = "" } // HasSuggestion returns true when an autosuggestion is available. @@ -308,6 +316,9 @@ func (m *InputModel) View() string { if m.suggestion != "" { hints += " [Tab] → " + m.SuggestionFull() } + if m.extraHints != "" { + hints += " " + m.extraHints + } b.WriteString(m.styles.Hint.Render(hints)) // Use 70% of terminal width for the dialog From 97395ca4336bc2de2d00fa05dda1f6fd27bc6249 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:06:45 +0000 Subject: [PATCH 3/6] fix: word-wrap footer hint line instead of reordering shortcuts The footer was reordered to put [w]/[+] first, but the right fix is to let the long hint string wrap at the terminal width. - Add wrapAtWidth() helper that breaks on word boundaries at m.width - Apply it to all three footer states (normal, filtered, search tab) - [w]save and [+]new tab are back at their natural end position; they become visible on the wrapped second line in narrow terminals - Bump list-height budget from height-5 to height-6 to reserve space for a potential second footer line (footer area now 4: \n\n + 2 lines) https://claude.ai/code/session_01GYo9iNKVfzrzZafwzRr7hz --- internal/tui/app.go | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 6f78c4c..1f7e6e9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -49,6 +49,32 @@ func normalizeGetCommand(command string) string { return trimmed } +// wrapAtWidth word-wraps text at the given column width, preserving words. +// Returns the text unchanged when width is 0 or the text already fits. +func wrapAtWidth(text string, width int) string { + if width <= 0 || len(text) <= width { + return text + } + words := strings.Fields(text) + var sb strings.Builder + lineLen := 0 + for i, word := range words { + if i == 0 { + sb.WriteString(word) + lineLen = len(word) + } else if lineLen+1+len(word) <= width { + sb.WriteByte(' ') + sb.WriteString(word) + lineLen += 1 + len(word) + } else { + sb.WriteByte('\n') + sb.WriteString(word) + lineLen = len(word) + } + } + return sb.String() +} + // getNamespaceFromCommand extracts the explicit namespace from a kubectl command string. // Returns "" if -A/--all-namespaces is used or no namespace flag is present. func getNamespaceFromCommand(command string) string { @@ -599,8 +625,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.header.SetWidth(msg.Width) m.tabs.SetWidth(msg.Width) m.search.SetWidth(msg.Width) - // List height = total height - header (1) - tabs (1) - search (1 if active or filtered) - footer area (3) - listHeight := msg.Height - 5 + // List height = total height - header (1) - tabs (1) - search (1 if active or filtered) - footer area (4: \n\n + up to 2 footer lines) + listHeight := msg.Height - 6 if m.search.IsActive() || m.search.IsFiltered() { listHeight-- } @@ -790,11 +816,11 @@ func (m *Model) View() string { } b.WriteString(hint) } else if m.search.IsFiltered() { - b.WriteString("[w]save [+]new tab [Esc] clear filter [/] modify filter [d]escribe [L]ogs [D]elete [e]dit [x]exec") + b.WriteString(wrapAtWidth("[Esc] clear filter [/] modify filter [d]escribe [L]ogs [D]elete [e]dit [x]exec [w]save [+]new tab", m.width)) } else if m.currentTab == SearchTabIndex { - b.WriteString("[Enter] enter command [w]save as tab [+]new tab [/]filter [r]efresh [q]uit") + b.WriteString(wrapAtWidth("[Enter] enter command [/]filter results [r]efresh [q]uit [w]save as tab [+]new tab", m.width)) } else { - b.WriteString("[w]save [+]new tab [d]escribe [L]ogs [Y]aml [D]elete [e]dit [x]exec [R]estart [c]ontext [n]amespace [s]ort [/]search [r]efresh [?]help") + b.WriteString(wrapAtWidth("[d]escribe [L]ogs [Y]aml [D]elete [e]dit [x]exec [R]estart [c]ontext [n]amespace [s]ort [/]search [r]efresh [?]help [w]save [+]new tab", m.width)) } return b.String() From e452d1e3222b39eb46f137f7a4944574a193a706 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:11:58 +0000 Subject: [PATCH 4/6] fix: show [w] only inside the floating dialog, not in the footer [w] is now a contextual hint: it appears exclusively in the command-edit dialog (opened with Enter on a config tab) via WithExtraHints(), where it is actually usable. Removed [w]save / [w]save as tab from all three footer lines (normal, filtered, search tab). [+]new tab remains in the footer since it is always available. https://claude.ai/code/session_01GYo9iNKVfzrzZafwzRr7hz --- internal/tui/app.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 1f7e6e9..13d113d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -816,11 +816,11 @@ func (m *Model) View() string { } b.WriteString(hint) } else if m.search.IsFiltered() { - b.WriteString(wrapAtWidth("[Esc] clear filter [/] modify filter [d]escribe [L]ogs [D]elete [e]dit [x]exec [w]save [+]new tab", m.width)) + b.WriteString(wrapAtWidth("[Esc] clear filter [/] modify filter [d]escribe [L]ogs [D]elete [e]dit [x]exec [+]new tab", m.width)) } else if m.currentTab == SearchTabIndex { - b.WriteString(wrapAtWidth("[Enter] enter command [/]filter results [r]efresh [q]uit [w]save as tab [+]new tab", m.width)) + b.WriteString(wrapAtWidth("[Enter] enter command [/]filter results [r]efresh [q]uit [+]new tab", m.width)) } else { - b.WriteString(wrapAtWidth("[d]escribe [L]ogs [Y]aml [D]elete [e]dit [x]exec [R]estart [c]ontext [n]amespace [s]ort [/]search [r]efresh [?]help [w]save [+]new tab", m.width)) + b.WriteString(wrapAtWidth("[d]escribe [L]ogs [Y]aml [D]elete [e]dit [x]exec [R]estart [c]ontext [n]amespace [s]ort [/]search [r]efresh [?]help [+]new tab", m.width)) } return b.String() From 544b3834540486713d299b0739e410f470891167 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:16:11 +0000 Subject: [PATCH 5/6] fix: replace w with Ctrl+W for save-to-config actions 'w' is a printable character and would be consumed by kubectl get expressions typed inside the input dialogs. Ctrl+W is a safe chord that does not conflict with any command text. Updated all three sites: main-view key handler, ViewSearchTab intercept, dialog extra-hint label, and README shortcut table. https://claude.ai/code/session_01GYo9iNKVfzrzZafwzRr7hz --- README.md | 6 +++--- internal/tui/app.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6cee011..79302f2 100644 --- a/README.md +++ b/README.md @@ -157,10 +157,10 @@ Press `/` to activate fuzzy filter mode. The filter uses fuzzy matching to filte | Key | Action | |-----|--------| | `+` | Add a new tab (prompts for name then command) | -| `w` | Save current tab command to `config.yaml` | -| `w` *(Search tab)* | Save search command as a new named tab | +| `Ctrl+W` | Save current tab command to `config.yaml` | +| `Ctrl+W` *(Search tab)* | Save search command as a new named tab | | `Enter` *(edit dialog)* | Apply new command (run only) | -| `w` *(edit dialog)* | Apply new command **and** save to `config.yaml` | +| `Ctrl+W` *(edit dialog)* | Apply new command **and** save to `config.yaml` | ### Other diff --git a/internal/tui/app.go b/internal/tui/app.go index 13d113d..89c23a7 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -382,9 +382,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle Search tab input mode if m.viewState == ViewSearchTab { - // Intercept "w" when editing an existing config tab command (no actionID): + // Intercept Ctrl+W when editing an existing config tab command (no actionID): // apply the typed command AND immediately save to config.yaml. - if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "w" && + if keyMsg, ok := msg.(tea.KeyMsg); ok && keyMsg.String() == "ctrl+w" && m.searchInput.ActionID() == "" && m.currentTab != SearchTabIndex { newValue := m.searchInput.Value() m.searchInput.Reset() @@ -513,7 +513,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { inp.WithHint(hint) inp.SetValue(currentCmd) if m.currentTab != SearchTabIndex { - inp.WithExtraHints("[w] apply + save to config.yaml") + inp.WithExtraHints("[Ctrl+W] apply + save to config.yaml") } inp.SetSize(m.width, m.height) m.searchInput = inp @@ -530,7 +530,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewState = ViewSearchTab return m, nil - case "w": + case "ctrl+w": // Save current tab's command to config.yaml if m.currentTab == SearchTabIndex { if m.searchCommand == "" { From f252eef93a606521135e309230a7cc1d1d972686 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 16:18:35 +0000 Subject: [PATCH 6/6] fix: display Ctrl+w (lowercase) in floating window hint https://claude.ai/code/session_01GYo9iNKVfzrzZafwzRr7hz --- internal/tui/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 89c23a7..98e6715 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -513,7 +513,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { inp.WithHint(hint) inp.SetValue(currentCmd) if m.currentTab != SearchTabIndex { - inp.WithExtraHints("[Ctrl+W] apply + save to config.yaml") + inp.WithExtraHints("[Ctrl+w] apply + save to config.yaml") } inp.SetSize(m.width, m.height) m.searchInput = inp