diff --git a/README.md b/README.md index 8c9bf28..79302f2 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) | +| `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) | +| `Ctrl+W` *(edit dialog)* | Apply new command **and** save to `config.yaml` | + ### Other | Key | Action | 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..98e6715 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 { @@ -106,8 +132,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 +153,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 +188,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 +196,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 +204,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 +212,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, @@ -358,24 +382,63 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Handle Search tab input mode if m.viewState == ViewSearchTab { + // 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() == "ctrl+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) 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 +498,53 @@ 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) + if m.currentTab != SearchTabIndex { + inp.WithExtraHints("[Ctrl+w] apply + save to config.yaml") + } + inp.SetSize(m.width, m.height) + m.searchInput = inp m.viewState = ViewSearchTab - m.searchInput.SetSize(m.width, m.height) + 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 + return m, nil + + case "ctrl+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) @@ -525,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-- } @@ -623,6 +723,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 @@ -700,11 +816,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(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("[Enter] enter command [/]filter results [r]efresh [q]uit") + b.WriteString(wrapAtWidth("[Enter] enter command [/]filter results [r]efresh [q]uit [+]new tab", m.width)) } 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(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() @@ -1451,6 +1567,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..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) @@ -145,6 +152,8 @@ func (m *InputModel) Reset() { m.historyIndex = -1 m.draft = "" m.suggestion = "" + m.actionID = "" + m.extraHints = "" } // HasSuggestion returns true when an autosuggestion is available. @@ -307,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 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 {