From abd1c61ae2dd32dff6fd324760f7ef9ab5bb5353 Mon Sep 17 00:00:00 2001 From: Web7Agent Date: Thu, 12 Feb 2026 09:05:27 +0000 Subject: [PATCH 01/11] feat(tui): add environment sub-view navigation with logger support --- cmd/overlock/tui/tui.go | 6 +- internal/tui/app.go | 92 ++++- internal/tui/environment.go | 690 ++++++++++++++++++++++++++++++++++++ 3 files changed, 772 insertions(+), 16 deletions(-) create mode 100644 internal/tui/environment.go diff --git a/cmd/overlock/tui/tui.go b/cmd/overlock/tui/tui.go index bf1263b..fbe14a9 100644 --- a/cmd/overlock/tui/tui.go +++ b/cmd/overlock/tui/tui.go @@ -1,20 +1,22 @@ package tui import ( + "context" "fmt" "os" tea "github.com/charmbracelet/bubbletea" "github.com/web-seven/overlock/internal/tui" + "go.uber.org/zap" ) // TUICmd represents the TUI command type TUICmd struct{} // Run executes the TUI command -func (c *TUICmd) Run() error { +func (c *TUICmd) Run(ctx context.Context, logger *zap.SugaredLogger) error { // Create the app model - model := tui.NewAppModel() + model := tui.NewAppModel(logger) // Create the Bubble Tea program p := tea.NewProgram(model, tea.WithAltScreen()) diff --git a/internal/tui/app.go b/internal/tui/app.go index e79ee4a..105105f 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/web-seven/overlock/cmd/overlock/version" + "go.uber.org/zap" ) const ( @@ -24,10 +25,13 @@ type AppModel struct { styles Styles quitting bool selectedItem string + envModel *EnvironmentModel + logger *zap.SugaredLogger + inSubView bool } // NewAppModel creates a new app model with the given items -func NewAppModel() *AppModel { +func NewAppModel(logger *zap.SugaredLogger) *AppModel { items := DefaultMenuItems() // Create list with default delegate @@ -39,11 +43,15 @@ func NewAppModel() *AppModel { l.SetShowHelp(false) l.SetShowTitle(false) + styles := NewStyles() + return &AppModel{ - list: l, - styles: NewStyles(), - quitting: false, - ready: false, + list: l, + styles: styles, + quitting: false, + ready: false, + logger: logger, + inSubView: false, } } @@ -90,6 +98,37 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + // If we're in a sub-view, handle escape to go back + if m.inSubView { + switch msg.String() { + case "esc": + // Only go back if we're in the list view of the sub-model + if m.envModel != nil && m.envModel.currentView == ViewList { + m.inSubView = false + m.envModel = nil + m.selectedItem = "" + return m, nil + } + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + } + + // Forward to sub-view + if m.envModel != nil { + var envCmd tea.Cmd + m.envModel, envCmd = m.envModel.Update(msg) + if envCmd != nil { + cmds = append(cmds, envCmd) + } + // Update viewport with environment view + m.viewport.SetContent(m.envModel.View()) + } + + return m, tea.Batch(cmds...) + } + + // Main menu navigation switch msg.String() { case "q", "ctrl+c": m.quitting = true @@ -97,19 +136,44 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": if item, ok := m.list.SelectedItem().(MenuItem); ok { m.selectedItem = item.Title() - m.viewport.SetContent(item.Title()) + // Handle different menu items + switch item.Title() { + case "Environment": + m.inSubView = true + m.envModel = NewEnvironmentModel(m.logger, m.styles) + m.envModel.width = m.viewport.Width + m.envModel.height = m.viewport.Height + var envCmd tea.Cmd + envCmd = m.envModel.Init() + if envCmd != nil { + cmds = append(cmds, envCmd) + } + m.viewport.SetContent(m.envModel.View()) + default: + m.viewport.SetContent(item.Title()) + } } } } - // Update list - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - cmds = append(cmds, cmd) - - // Update viewport - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) + // Update sub-models if active + if m.inSubView && m.envModel != nil { + var envCmd tea.Cmd + m.envModel, envCmd = m.envModel.Update(msg) + if envCmd != nil { + cmds = append(cmds, envCmd) + } + m.viewport.SetContent(m.envModel.View()) + } else { + // Update list + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + + // Update viewport + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } diff --git a/internal/tui/environment.go b/internal/tui/environment.go new file mode 100644 index 0000000..df04858 --- /dev/null +++ b/internal/tui/environment.go @@ -0,0 +1,690 @@ +package tui + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/web-seven/overlock/internal/engine" + "github.com/web-seven/overlock/pkg/environment" + "go.uber.org/zap" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +// EnvironmentView represents the current view state +type EnvironmentView int + +const ( + ViewList EnvironmentView = iota + ViewCreate + ViewDelete + ViewDetails +) + +// EnvironmentItem represents an environment in the list +type EnvironmentItem struct { + name string + engineType string + status string + description string +} + +// Implement list.Item interface +func (i EnvironmentItem) Title() string { return i.name } +func (i EnvironmentItem) Description() string { return fmt.Sprintf("%s • %s", i.engineType, i.status) } +func (i EnvironmentItem) FilterValue() string { return i.name } + +// EnvironmentModel manages the environment interface state +type EnvironmentModel struct { + logger *zap.SugaredLogger + styles Styles + width int + height int + currentView EnvironmentView + list list.Model + table table.Model + environments []EnvironmentItem + selectedEnv *EnvironmentItem + createInputs []textinput.Model + focusIndex int + confirmDelete bool + err error + loading bool + message string +} + +// NewEnvironmentModel creates a new environment management model +func NewEnvironmentModel(logger *zap.SugaredLogger, styles Styles) *EnvironmentModel { + // Create list for environment selection + items := []list.Item{} + delegate := list.NewDefaultDelegate() + l := list.New(items, delegate, 0, 0) + l.Title = "Environments" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.SetShowHelp(true) + + // Create table for environment list view + columns := []table.Column{ + {Title: "Name", Width: 30}, + {Title: "Type", Width: 10}, + {Title: "Status", Width: 15}, + } + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(10), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.Theme.Border). + BorderBottom(true). + Bold(true). + Foreground(styles.Theme.Primary) + s.Selected = s.Selected. + Foreground(styles.Theme.SelectedFg). + Background(styles.Theme.SelectedBg). + Bold(true) + t.SetStyles(s) + + // Create text inputs for create form + inputs := make([]textinput.Model, 4) + + inputs[0] = textinput.New() + inputs[0].Placeholder = "my-environment" + inputs[0].Focus() + inputs[0].CharLimit = 50 + inputs[0].Width = 40 + inputs[0].Prompt = "> " + + inputs[1] = textinput.New() + inputs[1].Placeholder = "kind" + inputs[1].CharLimit = 10 + inputs[1].Width = 40 + inputs[1].Prompt = "> " + + inputs[2] = textinput.New() + inputs[2].Placeholder = "80" + inputs[2].CharLimit = 5 + inputs[2].Width = 40 + inputs[2].Prompt = "> " + + inputs[3] = textinput.New() + inputs[3].Placeholder = "443" + inputs[3].CharLimit = 5 + inputs[3].Width = 40 + inputs[3].Prompt = "> " + + return &EnvironmentModel{ + logger: logger, + styles: styles, + currentView: ViewList, + list: l, + table: t, + environments: []EnvironmentItem{}, + createInputs: inputs, + focusIndex: 0, + } +} + +// Init initializes the environment model +func (m *EnvironmentModel) Init() tea.Cmd { + return m.loadEnvironments +} + +// Update handles messages +func (m *EnvironmentModel) Update(msg tea.Msg) (*EnvironmentModel, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.table.SetWidth(msg.Width - 4) + m.table.SetHeight(msg.Height - 10) + m.list.SetSize(msg.Width-4, msg.Height-10) + + case tea.KeyMsg: + switch m.currentView { + case ViewList: + return m.handleListKeys(msg) + case ViewCreate: + return m.handleCreateKeys(msg) + case ViewDelete: + return m.handleDeleteKeys(msg) + case ViewDetails: + return m.handleDetailsKeys(msg) + } + + case environmentsLoadedMsg: + m.environments = msg.environments + m.loading = false + m.updateTableRows() + m.updateListItems() + + case environmentCreatedMsg: + m.loading = false + m.message = fmt.Sprintf("Environment '%s' created successfully!", msg.name) + m.currentView = ViewList + return m, m.loadEnvironments + + case environmentDeletedMsg: + m.loading = false + m.message = fmt.Sprintf("Environment '%s' deleted successfully!", msg.name) + m.currentView = ViewList + return m, m.loadEnvironments + + case errorMsg: + m.loading = false + m.err = msg.err + m.message = "" + + case clearMessageMsg: + m.message = "" + m.err = nil + } + + // Update sub-components + switch m.currentView { + case ViewList: + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + case ViewCreate: + for i := range m.createInputs { + m.createInputs[i], cmd = m.createInputs[i].Update(msg) + cmds = append(cmds, cmd) + } + } + + return m, tea.Batch(cmds...) +} + +// View renders the environment interface +func (m *EnvironmentModel) View() string { + if m.loading { + return m.styles.TextStyle.Render("Loading...") + } + + var content string + switch m.currentView { + case ViewList: + content = m.viewList() + case ViewCreate: + content = m.viewCreate() + case ViewDelete: + content = m.viewDelete() + case ViewDetails: + content = m.viewDetails() + } + + return content +} + +// viewList renders the environment list view +func (m *EnvironmentModel) viewList() string { + var b strings.Builder + + // Title + title := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Environment Management") + b.WriteString(title) + b.WriteString("\n\n") + + // Show message or error + if m.message != "" { + msg := m.styles.SuccessStyle.Render("✓ " + m.message) + b.WriteString(msg) + b.WriteString("\n\n") + } + if m.err != nil { + errMsg := m.styles.ErrorStyle.Render("✗ Error: " + m.err.Error()) + b.WriteString(errMsg) + b.WriteString("\n\n") + } + + // Environment table + if len(m.environments) == 0 { + noEnvMsg := m.styles.MutedTextStyle.Render("No environments found. Press 'c' to create one.") + b.WriteString(noEnvMsg) + b.WriteString("\n") + } else { + b.WriteString(m.table.View()) + b.WriteString("\n") + } + + // Help + helpStyle := m.styles.MutedTextStyle + help := "\n" + helpStyle.Render("c: create • d: delete • enter: details • r: refresh • esc: back") + b.WriteString(help) + + return b.String() +} + +// viewCreate renders the create environment form +func (m *EnvironmentModel) viewCreate() string { + var b strings.Builder + + // Title + title := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Create New Environment") + b.WriteString(title) + b.WriteString("\n\n") + + // Form fields + fields := []string{"Name", "Engine (kind/k3s/k3d)", "HTTP Port", "HTTPS Port"} + for i, field := range fields { + b.WriteString(m.styles.TextStyle.Render(field + ":")) + b.WriteString("\n") + + if i == m.focusIndex { + b.WriteString(m.createInputs[i].View()) + } else { + b.WriteString(m.styles.MutedTextStyle.Render(m.createInputs[i].View())) + } + b.WriteString("\n\n") + } + + // Show error if any + if m.err != nil { + errMsg := m.styles.ErrorStyle.Render("✗ Error: " + m.err.Error()) + b.WriteString(errMsg) + b.WriteString("\n\n") + } + + // Help + helpStyle := m.styles.MutedTextStyle + help := helpStyle.Render("tab/shift+tab: navigate • enter: submit • esc: cancel") + b.WriteString(help) + + return b.String() +} + +// viewDelete renders the delete confirmation dialog +func (m *EnvironmentModel) viewDelete() string { + var b strings.Builder + + // Title + title := m.styles.ErrorStyle.Render("⚠ Delete Environment") + b.WriteString(title) + b.WriteString("\n\n") + + if m.selectedEnv == nil { + b.WriteString(m.styles.TextStyle.Render("No environment selected.")) + b.WriteString("\n\n") + b.WriteString(m.styles.MutedTextStyle.Render("Press 'esc' to go back.")) + return b.String() + } + + // Confirmation message + msg := fmt.Sprintf("Are you sure you want to delete environment '%s'?", m.selectedEnv.name) + b.WriteString(m.styles.TextStyle.Render(msg)) + b.WriteString("\n\n") + + // Environment details + details := fmt.Sprintf("Type: %s\nStatus: %s", m.selectedEnv.engineType, m.selectedEnv.status) + b.WriteString(m.styles.MutedTextStyle.Render(details)) + b.WriteString("\n\n") + + // Warning + warning := m.styles.WarningStyle.Render("⚠ This action cannot be undone!") + b.WriteString(warning) + b.WriteString("\n\n") + + // Show error if any + if m.err != nil { + errMsg := m.styles.ErrorStyle.Render("✗ Error: " + m.err.Error()) + b.WriteString(errMsg) + b.WriteString("\n\n") + } + + // Help + helpStyle := m.styles.MutedTextStyle + help := helpStyle.Render("y: confirm delete • n/esc: cancel") + b.WriteString(help) + + return b.String() +} + +// viewDetails renders the environment details view +func (m *EnvironmentModel) viewDetails() string { + var b strings.Builder + + // Title + title := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Environment Details") + b.WriteString(title) + b.WriteString("\n\n") + + if m.selectedEnv == nil { + b.WriteString(m.styles.TextStyle.Render("No environment selected.")) + b.WriteString("\n\n") + b.WriteString(m.styles.MutedTextStyle.Render("Press 'esc' to go back.")) + return b.String() + } + + // Environment details + nameLabel := m.styles.KeyStyle.Render("Name:") + nameValue := m.styles.ValueStyle.Render(m.selectedEnv.name) + b.WriteString(fmt.Sprintf("%s %s\n", nameLabel, nameValue)) + + typeLabel := m.styles.KeyStyle.Render("Type:") + typeValue := m.styles.ValueStyle.Render(m.selectedEnv.engineType) + b.WriteString(fmt.Sprintf("%s %s\n", typeLabel, typeValue)) + + statusLabel := m.styles.KeyStyle.Render("Status:") + statusStyle := m.styles.SuccessStyle + if m.selectedEnv.status == "inactive" { + statusStyle = m.styles.MutedTextStyle + } + statusValue := statusStyle.Render(m.selectedEnv.status) + b.WriteString(fmt.Sprintf("%s %s\n", statusLabel, statusValue)) + + b.WriteString("\n") + + // Help + helpStyle := m.styles.MutedTextStyle + help := helpStyle.Render("esc: back") + b.WriteString(help) + + return b.String() +} + +// Key handlers + +func (m *EnvironmentModel) handleListKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + var cmd tea.Cmd + + switch msg.String() { + case "c": + // Create new environment + m.currentView = ViewCreate + m.err = nil + m.message = "" + m.focusIndex = 0 + m.createInputs[0].Focus() + for i := 1; i < len(m.createInputs); i++ { + m.createInputs[i].Blur() + } + return m, nil + + case "d": + // Delete environment + if len(m.environments) > 0 { + selectedIdx := m.table.Cursor() + if selectedIdx < len(m.environments) { + m.selectedEnv = &m.environments[selectedIdx] + m.currentView = ViewDelete + m.err = nil + m.message = "" + } + } + return m, nil + + case "enter": + // View details + if len(m.environments) > 0 { + selectedIdx := m.table.Cursor() + if selectedIdx < len(m.environments) { + m.selectedEnv = &m.environments[selectedIdx] + m.currentView = ViewDetails + m.err = nil + m.message = "" + } + } + return m, nil + + case "r": + // Refresh list + m.loading = true + m.err = nil + m.message = "" + return m, m.loadEnvironments + + default: + // Clear messages on any other key + if m.message != "" || m.err != nil { + m.message = "" + m.err = nil + } + m.table, cmd = m.table.Update(msg) + return m, cmd + } +} + +func (m *EnvironmentModel) handleCreateKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + switch msg.String() { + case "esc": + // Cancel creation + m.currentView = ViewList + m.err = nil + // Reset form + for i := range m.createInputs { + m.createInputs[i].SetValue("") + } + return m, nil + + case "tab", "shift+tab", "up", "down": + // Navigate between inputs + if msg.String() == "up" || msg.String() == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + + if m.focusIndex < 0 { + m.focusIndex = len(m.createInputs) - 1 + } else if m.focusIndex >= len(m.createInputs) { + m.focusIndex = 0 + } + + for i := range m.createInputs { + if i == m.focusIndex { + m.createInputs[i].Focus() + } else { + m.createInputs[i].Blur() + } + } + return m, nil + + case "enter": + // Submit form + name := m.createInputs[0].Value() + engine := m.createInputs[1].Value() + httpPort := m.createInputs[2].Value() + httpsPort := m.createInputs[3].Value() + + // Validate + if name == "" { + m.err = fmt.Errorf("environment name is required") + return m, nil + } + if engine == "" { + engine = "kind" + } + if httpPort == "" { + httpPort = "80" + } + if httpsPort == "" { + httpsPort = "443" + } + + m.loading = true + m.err = nil + return m, m.createEnvironment(name, engine, httpPort, httpsPort) + } + + return m, nil +} + +func (m *EnvironmentModel) handleDeleteKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + switch msg.String() { + case "y": + // Confirm delete + if m.selectedEnv != nil { + m.loading = true + m.err = nil + return m, m.deleteEnvironment(m.selectedEnv.name, m.selectedEnv.engineType) + } + return m, nil + + case "n", "esc": + // Cancel delete + m.currentView = ViewList + m.selectedEnv = nil + m.err = nil + return m, nil + } + + return m, nil +} + +func (m *EnvironmentModel) handleDetailsKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + switch msg.String() { + case "esc": + // Go back to list + m.currentView = ViewList + m.selectedEnv = nil + m.err = nil + return m, nil + } + + return m, nil +} + +// Data loading and operations + +func (m *EnvironmentModel) loadEnvironments() tea.Msg { + environments := []EnvironmentItem{} + + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + kubeconfig = filepath.Join(os.Getenv("HOME"), ".kube", "config") + } + + configFile, err := clientcmd.LoadFromFile(kubeconfig) + if err != nil { + return errorMsg{err: err} + } + + for name := range configFile.Contexts { + configClient, err := config.GetConfigWithContext(name) + if err != nil { + continue + } + + if engine.IsHelmReleaseFound(configClient) { + types := regexp.MustCompile(`(\w+)`).FindStringSubmatch(name) + engineType := "unknown" + if len(types) > 0 { + engineType = strings.ToLower(types[0]) + } + + status := "active" + // TODO: Implement actual status checking + // For now, we'll assume all are active + + environments = append(environments, EnvironmentItem{ + name: name, + engineType: engineType, + status: status, + }) + } + } + + return environmentsLoadedMsg{environments: environments} +} + +func (m *EnvironmentModel) createEnvironment(name, engine, httpPort, httpsPort string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + // Parse ports + var http, https int + fmt.Sscanf(httpPort, "%d", &http) + fmt.Sscanf(httpsPort, "%d", &https) + + if http == 0 { + http = 80 + } + if https == 0 { + https = 443 + } + + // Create environment using the existing package + err := environment. + New(engine, name). + WithHttpPort(http). + WithHttpsPort(https). + Create(ctx, m.logger) + + if err != nil { + return errorMsg{err: err} + } + + return environmentCreatedMsg{name: name} + } +} + +func (m *EnvironmentModel) deleteEnvironment(name, engine string) tea.Cmd { + return func() tea.Msg { + // Delete environment using the existing package + err := environment. + New(engine, name). + Delete(true, m.logger) // true = skip confirmation (we already confirmed in TUI) + + if err != nil { + return errorMsg{err: err} + } + + return environmentDeletedMsg{name: name} + } +} + +func (m *EnvironmentModel) updateTableRows() { + rows := []table.Row{} + for _, env := range m.environments { + rows = append(rows, table.Row{ + env.name, + strings.ToUpper(env.engineType), + env.status, + }) + } + m.table.SetRows(rows) +} + +func (m *EnvironmentModel) updateListItems() { + items := []list.Item{} + for _, env := range m.environments { + items = append(items, env) + } + m.list.SetItems(items) +} + +// Messages + +type environmentsLoadedMsg struct { + environments []EnvironmentItem +} + +type environmentCreatedMsg struct { + name string +} + +type environmentDeletedMsg struct { + name string +} + +type errorMsg struct { + err error +} + +type clearMessageMsg struct{} From 69b55e54d27fe15c81735c431d4672333c67c33c Mon Sep 17 00:00:00 2001 From: Web7Agent Date: Thu, 12 Feb 2026 09:17:28 +0000 Subject: [PATCH 02/11] fix(tui): enable text input for environment creation form fields --- internal/tui/environment.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/tui/environment.go b/internal/tui/environment.go index df04858..0a0e439 100644 --- a/internal/tui/environment.go +++ b/internal/tui/environment.go @@ -461,6 +461,9 @@ func (m *EnvironmentModel) handleListKeys(msg tea.KeyMsg) (*EnvironmentModel, te } func (m *EnvironmentModel) handleCreateKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg.String() { case "esc": // Cancel creation @@ -522,7 +525,13 @@ func (m *EnvironmentModel) handleCreateKeys(msg tea.KeyMsg) (*EnvironmentModel, return m, m.createEnvironment(name, engine, httpPort, httpsPort) } - return m, nil + // For all other keys (regular text input), update the focused input + for i := range m.createInputs { + m.createInputs[i], cmd = m.createInputs[i].Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) } func (m *EnvironmentModel) handleDeleteKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { From 8e05449ba898fe403ad3387d773d0fe40d6881f5 Mon Sep 17 00:00:00 2001 From: Web7Agent Date: Thu, 12 Feb 2026 09:27:12 +0000 Subject: [PATCH 03/11] feat(tui): add real-time log viewer panel to environment sub-view --- cmd/overlock/tui/tui.go | 3 + internal/tui/app.go | 155 +++++++++++++++++++++++++++++++++++++--- internal/tui/logger.go | 132 ++++++++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 internal/tui/logger.go diff --git a/cmd/overlock/tui/tui.go b/cmd/overlock/tui/tui.go index fbe14a9..b2b3297 100644 --- a/cmd/overlock/tui/tui.go +++ b/cmd/overlock/tui/tui.go @@ -21,6 +21,9 @@ func (c *TUICmd) Run(ctx context.Context, logger *zap.SugaredLogger) error { // Create the Bubble Tea program p := tea.NewProgram(model, tea.WithAltScreen()) + // Set the program reference for log messages + model.SetProgram(p) + // Run the program finalModel, err := p.Run() if err != nil { diff --git a/internal/tui/app.go b/internal/tui/app.go index 105105f..42f71d2 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -12,13 +12,16 @@ import ( ) const ( - menuWidth = 30 + menuWidth = 30 + logWidth = 50 + minLogLines = 10 ) // AppModel represents the main TUI application model type AppModel struct { list list.Model viewport viewport.Model + logViewport viewport.Model ready bool width int height int @@ -27,7 +30,10 @@ type AppModel struct { selectedItem string envModel *EnvironmentModel logger *zap.SugaredLogger + logBuffer *LogBuffer + tuiLogger *zap.SugaredLogger inSubView bool + showLogs bool } // NewAppModel creates a new app model with the given items @@ -45,13 +51,20 @@ func NewAppModel(logger *zap.SugaredLogger) *AppModel { styles := NewStyles() + // Create log buffer and TUI logger + logBuffer := NewLogBuffer() + tuiLogger := CreateTUILogger(logBuffer) + return &AppModel{ list: l, styles: styles, quitting: false, ready: false, logger: logger, + logBuffer: logBuffer, + tuiLogger: tuiLogger, inSubView: false, + showLogs: false, } } @@ -73,7 +86,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { footerHeight := lipgloss.Height(m.footerView()) verticalMarginHeight := headerHeight + footerHeight + // Calculate content width based on whether logs are shown contentWidth := m.width - menuWidth - 1 + if m.showLogs { + contentWidth = contentWidth - logWidth - 1 + } if contentWidth < 20 { contentWidth = 20 } @@ -86,17 +103,26 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.list.SetSize(menuWidth-2, contentHeight) if !m.ready { - // Initialize viewport + // Initialize viewports m.viewport = viewport.New(contentWidth, contentHeight) m.viewport.SetContent("") + m.logViewport = viewport.New(logWidth-2, contentHeight) + m.logViewport.SetContent("") m.ready = true } else { m.viewport.Width = contentWidth m.viewport.Height = contentHeight + m.logViewport.Width = logWidth - 2 + m.logViewport.Height = contentHeight } return m, nil + case LogMsg: + // Update log viewport with new log entry + m.updateLogView() + return m, nil + case tea.KeyMsg: // If we're in a sub-view, handle escape to go back if m.inSubView { @@ -105,8 +131,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Only go back if we're in the list view of the sub-model if m.envModel != nil && m.envModel.currentView == ViewList { m.inSubView = false + m.showLogs = false m.envModel = nil m.selectedItem = "" + // Recalculate layout without logs + m.viewport.Width = m.width - menuWidth - 1 return m, nil } case "q", "ctrl+c": @@ -140,7 +169,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch item.Title() { case "Environment": m.inSubView = true - m.envModel = NewEnvironmentModel(m.logger, m.styles) + m.showLogs = true + m.logBuffer.Clear() // Clear previous logs + m.envModel = NewEnvironmentModel(m.tuiLogger, m.styles) m.envModel.width = m.viewport.Width m.envModel.height = m.viewport.Height var envCmd tea.Cmd @@ -149,6 +180,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, envCmd) } m.viewport.SetContent(m.envModel.View()) + // Recalculate layout with logs visible + m.viewport.Width = m.width - menuWidth - logWidth - 2 + m.logViewport.Width = logWidth - 2 default: m.viewport.SetContent(item.Title()) } @@ -194,7 +228,7 @@ func (m *AppModel) View() string { doc.WriteString(m.headerView()) doc.WriteString("\n") - // Main content area (menu + content) + // Main content area (menu + content + optional logs) { borderStyle := lipgloss.NewStyle().Foreground(m.styles.Theme.Border) @@ -208,19 +242,40 @@ func (m *AppModel) View() string { Width(m.viewport.Width). Height(m.viewport.Height) + // Log area style + logStyle := lipgloss.NewStyle(). + Width(logWidth - 2). + Height(m.viewport.Height) + // Render menu and content menuRendered := menuStyle.Render(m.list.View()) contentRendered := contentStyle.Render(m.viewport.View()) + // Render logs if enabled + var logRendered string + if m.showLogs { + m.updateLogView() + logTitle := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Logs") + logContent := m.logViewport.View() + logRendered = logStyle.Render(logTitle + "\n" + logContent) + } + // Split into lines menuLines := strings.Split(menuRendered, "\n") contentLines := strings.Split(contentRendered, "\n") + var logLines []string + if m.showLogs { + logLines = strings.Split(logRendered, "\n") + } // Ensure same number of lines maxLines := len(menuLines) if len(contentLines) > maxLines { maxLines = len(contentLines) } + if m.showLogs && len(logLines) > maxLines { + maxLines = len(logLines) + } // Build bordered lines with proper alignment var borderedLines []string @@ -235,7 +290,20 @@ func (m *AppModel) View() string { } // Pad menu line to exact width menuLine = padRight(menuLine, menuWidth-2) - line := borderStyle.Render("│") + menuLine + borderStyle.Render("│") + contentLine + borderStyle.Render("│") + + line := borderStyle.Render("│") + menuLine + borderStyle.Render("│") + contentLine + + // Add log section if enabled + if m.showLogs { + logLine := "" + if i < len(logLines) { + logLine = logLines[i] + } + logLine = padRight(logLine, logWidth-2) + line = line + borderStyle.Render("│") + logLine + } + + line = line + borderStyle.Render("│") borderedLines = append(borderedLines, line) } doc.WriteString(strings.Join(borderedLines, "\n")) @@ -269,12 +337,63 @@ func (m *AppModel) headerView() string { innerWidth := m.width - 2 leftPart := strings.Repeat("─", menuWidth-2) junction := "┬" - rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) - bottomLine := borderStyle.Render("├" + leftPart + junction + rightPart + "┤") + + var bottomLine string + if m.showLogs { + // Calculate middle part width (content area) + middleWidth := innerWidth - menuWidth - logWidth + 2 + if middleWidth < 0 { + middleWidth = 0 + } + middlePart := strings.Repeat("─", middleWidth) + rightPart := strings.Repeat("─", logWidth-2) + bottomLine = borderStyle.Render("├" + leftPart + junction + middlePart + junction + rightPart + "┤") + } else { + rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) + bottomLine = borderStyle.Render("├" + leftPart + junction + rightPart + "┤") + } return lipgloss.JoinVertical(lipgloss.Left, topLine, titleLine, bottomLine) } +// updateLogView updates the log viewport with current log entries +func (m *AppModel) updateLogView() { + if m.logBuffer == nil { + return + } + + entries := m.logBuffer.GetEntries() + var logContent strings.Builder + + // Render log entries + for _, entry := range entries { + var levelStyle lipgloss.Style + switch entry.Level.String() { + case "ERROR", "error": + levelStyle = m.styles.ErrorStyle + case "WARN", "warn": + levelStyle = m.styles.WarningStyle + case "INFO", "info": + levelStyle = m.styles.SuccessStyle + default: + levelStyle = m.styles.MutedTextStyle + } + + logLine := lipgloss.JoinHorizontal( + lipgloss.Left, + m.styles.MutedTextStyle.Render("["+entry.Time+"] "), + levelStyle.Render(entry.Level.CapitalString()+" "), + m.styles.TextStyle.Render(entry.Message), + ) + logContent.WriteString(logLine) + logContent.WriteString("\n") + } + + m.logViewport.SetContent(logContent.String()) + // Auto-scroll to bottom + m.logViewport.GotoBottom() +} + // footerView renders the footer with shortcuts and status func (m *AppModel) footerView() string { w := lipgloss.Width @@ -348,8 +467,21 @@ func (m *AppModel) footerView() string { innerWidth := m.width - 2 leftPart := strings.Repeat("─", menuWidth-2) junction := "┴" - rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) - topLine := borderStyle.Render("├" + leftPart + junction + rightPart + "┤") + + var topLine string + if m.showLogs { + // Calculate middle part width (content area) + middleWidth := innerWidth - menuWidth - logWidth + 2 + if middleWidth < 0 { + middleWidth = 0 + } + middlePart := strings.Repeat("─", middleWidth) + rightPart := strings.Repeat("─", logWidth-2) + topLine = borderStyle.Render("├" + leftPart + junction + middlePart + junction + rightPart + "┤") + } else { + rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) + topLine = borderStyle.Render("├" + leftPart + junction + rightPart + "┤") + } // Status bar with side borders - ensure single line with exact width statusBarContent := statusBarStyle.Width(m.width - 2).MaxHeight(1).Render(bar) @@ -371,6 +503,11 @@ func (m *AppModel) SetViewportContent(content string) { m.viewport.SetContent(content) } +// SetProgram sets the Bubble Tea program reference for sending log messages +func (m *AppModel) SetProgram(p *tea.Program) { + m.logBuffer.SetProgram(p) +} + func max(a, b int) int { if a > b { return a diff --git a/internal/tui/logger.go b/internal/tui/logger.go new file mode 100644 index 0000000..9a7af19 --- /dev/null +++ b/internal/tui/logger.go @@ -0,0 +1,132 @@ +package tui + +import ( + "sync" + + tea "github.com/charmbracelet/bubbletea" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// LogEntry represents a single log entry +type LogEntry struct { + Level zapcore.Level + Message string + Time string +} + +// LogSink is a custom zap core that captures logs for TUI display +type LogSink struct { + zapcore.LevelEnabler + encoder zapcore.Encoder + output *LogBuffer +} + +// LogBuffer stores log entries and sends them to the TUI +type LogBuffer struct { + mu sync.RWMutex + entries []LogEntry + program *tea.Program +} + +// NewLogBuffer creates a new log buffer +func NewLogBuffer() *LogBuffer { + return &LogBuffer{ + entries: make([]LogEntry, 0), + } +} + +// SetProgram sets the Bubble Tea program for sending messages +func (b *LogBuffer) SetProgram(p *tea.Program) { + b.mu.Lock() + defer b.mu.Unlock() + b.program = p +} + +// Add appends a log entry and sends it to the TUI +func (b *LogBuffer) Add(entry LogEntry) { + b.mu.Lock() + defer b.mu.Unlock() + b.entries = append(b.entries, entry) + + // Send message to TUI if program is set + if b.program != nil { + b.program.Send(LogMsg{Entry: entry}) + } +} + +// GetEntries returns all log entries +func (b *LogBuffer) GetEntries() []LogEntry { + b.mu.RLock() + defer b.mu.RUnlock() + + // Return a copy to avoid race conditions + entries := make([]LogEntry, len(b.entries)) + copy(entries, b.entries) + return entries +} + +// Clear removes all log entries +func (b *LogBuffer) Clear() { + b.mu.Lock() + defer b.mu.Unlock() + b.entries = make([]LogEntry, 0) +} + +// LogMsg is a Bubble Tea message for log updates +type LogMsg struct { + Entry LogEntry +} + +// NewLogSink creates a new log sink +func NewLogSink(level zapcore.Level, output *LogBuffer) *LogSink { + encoderConfig := zap.NewDevelopmentEncoderConfig() + encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05") + encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + + return &LogSink{ + LevelEnabler: level, + encoder: zapcore.NewConsoleEncoder(encoderConfig), + output: output, + } +} + +// With adds structured context to the logger +func (s *LogSink) With(fields []zapcore.Field) zapcore.Core { + return s +} + +// Check determines whether the supplied Entry should be logged +func (s *LogSink) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if s.Enabled(entry.Level) { + return checked.AddCore(entry, s) + } + return checked +} + +// Write serializes the Entry and any Fields supplied +func (s *LogSink) Write(entry zapcore.Entry, fields []zapcore.Field) error { + // Create log entry + logEntry := LogEntry{ + Level: entry.Level, + Message: entry.Message, + Time: entry.Time.Format("15:04:05"), + } + + // Add to buffer + s.output.Add(logEntry) + + return nil +} + +// Sync flushes buffered logs +func (s *LogSink) Sync() error { + return nil +} + +// CreateTUILogger creates a logger that outputs to the TUI log buffer +func CreateTUILogger(buffer *LogBuffer) *zap.SugaredLogger { + core := NewLogSink(zapcore.DebugLevel, buffer) + logger := zap.New(core) + return logger.Sugar() +} From 16768f6e58f943b4867214ce2cff5fbba0f11b60 Mon Sep 17 00:00:00 2001 From: Evgheni Poleacov Date: Thu, 12 Feb 2026 13:03:49 +0200 Subject: [PATCH 04/11] 359: fix logger Enabled method call --- internal/tui/logger.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui/logger.go b/internal/tui/logger.go index 9a7af19..8081609 100644 --- a/internal/tui/logger.go +++ b/internal/tui/logger.go @@ -98,7 +98,7 @@ func (s *LogSink) With(fields []zapcore.Field) zapcore.Core { // Check determines whether the supplied Entry should be logged func (s *LogSink) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { - if s.Enabled(entry.Level) { + if s.LevelEnabler.Enabled(entry.Level) { return checked.AddCore(entry, s) } return checked From c2f0ed4c88ea1e5223cb99e27e7ddd62e8c64144 Mon Sep 17 00:00:00 2001 From: Evgheni Poleacov Date: Thu, 12 Feb 2026 16:00:04 +0200 Subject: [PATCH 05/11] 359: fix sidebar layout height and width calculations --- internal/tui/app.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 42f71d2..49396c3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -87,9 +87,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { verticalMarginHeight := headerHeight + footerHeight // Calculate content width based on whether logs are shown + // Without logs: │menu(menuWidth-2)│content│ = 3 borders + // With logs: │menu(menuWidth-2)│content│logs(logWidth-2)│ = 4 borders contentWidth := m.width - menuWidth - 1 if m.showLogs { - contentWidth = contentWidth - logWidth - 1 + contentWidth = m.width - menuWidth - logWidth } if contentWidth < 20 { contentWidth = 20 @@ -102,18 +104,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update list size m.list.SetSize(menuWidth-2, contentHeight) + // Calculate log height (subtract 1 for "Logs" title) + logHeight := contentHeight - 1 + if logHeight < 1 { + logHeight = 1 + } + if !m.ready { // Initialize viewports m.viewport = viewport.New(contentWidth, contentHeight) m.viewport.SetContent("") - m.logViewport = viewport.New(logWidth-2, contentHeight) + m.logViewport = viewport.New(logWidth-2, logHeight) m.logViewport.SetContent("") m.ready = true } else { m.viewport.Width = contentWidth m.viewport.Height = contentHeight m.logViewport.Width = logWidth - 2 - m.logViewport.Height = contentHeight + m.logViewport.Height = logHeight } return m, nil @@ -181,7 +189,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.viewport.SetContent(m.envModel.View()) // Recalculate layout with logs visible - m.viewport.Width = m.width - menuWidth - logWidth - 2 + m.viewport.Width = m.width - menuWidth - logWidth m.logViewport.Width = logWidth - 2 default: m.viewport.SetContent(item.Title()) From c4322d2633942fab4a4a518df75519c6728af7bd Mon Sep 17 00:00:00 2001 From: Evgheni Poleacov Date: Thu, 12 Feb 2026 16:07:40 +0200 Subject: [PATCH 06/11] 359: add left padding to content and log panels --- internal/tui/app.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 49396c3..221b7af 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -245,15 +245,17 @@ func (m *AppModel) View() string { Width(menuWidth - 2). Height(m.viewport.Height) - // Content area style + // Content area style with left padding contentStyle := lipgloss.NewStyle(). Width(m.viewport.Width). - Height(m.viewport.Height) + Height(m.viewport.Height). + PaddingLeft(1) - // Log area style + // Log area style with left padding logStyle := lipgloss.NewStyle(). Width(logWidth - 2). - Height(m.viewport.Height) + Height(m.viewport.Height). + PaddingLeft(1) // Render menu and content menuRendered := menuStyle.Render(m.list.View()) From 0e17c96004523f268f0427a3746c00e6934046bb Mon Sep 17 00:00:00 2001 From: Evgheni Poleacov Date: Thu, 12 Feb 2026 16:16:38 +0200 Subject: [PATCH 07/11] 359: rename log viewport to rightSidebar for reusability --- internal/tui/app.go | 133 ++++++++++++++++++++++---------------------- 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 221b7af..079e1aa 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -12,28 +12,27 @@ import ( ) const ( - menuWidth = 30 - logWidth = 50 - minLogLines = 10 + menuWidth = 30 + rightSidebarWidth = 50 ) // AppModel represents the main TUI application model type AppModel struct { - list list.Model - viewport viewport.Model - logViewport viewport.Model - ready bool - width int - height int - styles Styles - quitting bool - selectedItem string - envModel *EnvironmentModel - logger *zap.SugaredLogger - logBuffer *LogBuffer - tuiLogger *zap.SugaredLogger - inSubView bool - showLogs bool + list list.Model + viewport viewport.Model + rightSidebar viewport.Model + ready bool + width int + height int + styles Styles + quitting bool + selectedItem string + envModel *EnvironmentModel + logger *zap.SugaredLogger + logBuffer *LogBuffer + tuiLogger *zap.SugaredLogger + inSubView bool + showRightSidebar bool } // NewAppModel creates a new app model with the given items @@ -64,7 +63,7 @@ func NewAppModel(logger *zap.SugaredLogger) *AppModel { logBuffer: logBuffer, tuiLogger: tuiLogger, inSubView: false, - showLogs: false, + showRightSidebar: false, } } @@ -88,10 +87,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Calculate content width based on whether logs are shown // Without logs: │menu(menuWidth-2)│content│ = 3 borders - // With logs: │menu(menuWidth-2)│content│logs(logWidth-2)│ = 4 borders + // With logs: │menu(menuWidth-2)│content│logs(rightSidebarWidth-2)│ = 4 borders contentWidth := m.width - menuWidth - 1 - if m.showLogs { - contentWidth = m.width - menuWidth - logWidth + if m.showRightSidebar { + contentWidth = m.width - menuWidth - rightSidebarWidth } if contentWidth < 20 { contentWidth = 20 @@ -105,30 +104,30 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.list.SetSize(menuWidth-2, contentHeight) // Calculate log height (subtract 1 for "Logs" title) - logHeight := contentHeight - 1 - if logHeight < 1 { - logHeight = 1 + sidebarHeight := contentHeight - 1 + if sidebarHeight < 1 { + sidebarHeight = 1 } if !m.ready { // Initialize viewports m.viewport = viewport.New(contentWidth, contentHeight) m.viewport.SetContent("") - m.logViewport = viewport.New(logWidth-2, logHeight) - m.logViewport.SetContent("") + m.rightSidebar = viewport.New(rightSidebarWidth-2, sidebarHeight) + m.rightSidebar.SetContent("") m.ready = true } else { m.viewport.Width = contentWidth m.viewport.Height = contentHeight - m.logViewport.Width = logWidth - 2 - m.logViewport.Height = logHeight + m.rightSidebar.Width = rightSidebarWidth - 2 + m.rightSidebar.Height = sidebarHeight } return m, nil case LogMsg: // Update log viewport with new log entry - m.updateLogView() + m.updateRightSidebar() return m, nil case tea.KeyMsg: @@ -139,7 +138,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Only go back if we're in the list view of the sub-model if m.envModel != nil && m.envModel.currentView == ViewList { m.inSubView = false - m.showLogs = false + m.showRightSidebar = false m.envModel = nil m.selectedItem = "" // Recalculate layout without logs @@ -177,7 +176,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch item.Title() { case "Environment": m.inSubView = true - m.showLogs = true + m.showRightSidebar = true m.logBuffer.Clear() // Clear previous logs m.envModel = NewEnvironmentModel(m.tuiLogger, m.styles) m.envModel.width = m.viewport.Width @@ -189,8 +188,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.viewport.SetContent(m.envModel.View()) // Recalculate layout with logs visible - m.viewport.Width = m.width - menuWidth - logWidth - m.logViewport.Width = logWidth - 2 + m.viewport.Width = m.width - menuWidth - rightSidebarWidth + m.rightSidebar.Width = rightSidebarWidth - 2 default: m.viewport.SetContent(item.Title()) } @@ -252,8 +251,8 @@ func (m *AppModel) View() string { PaddingLeft(1) // Log area style with left padding - logStyle := lipgloss.NewStyle(). - Width(logWidth - 2). + sidebarStyle := lipgloss.NewStyle(). + Width(rightSidebarWidth - 2). Height(m.viewport.Height). PaddingLeft(1) @@ -262,20 +261,20 @@ func (m *AppModel) View() string { contentRendered := contentStyle.Render(m.viewport.View()) // Render logs if enabled - var logRendered string - if m.showLogs { - m.updateLogView() - logTitle := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Logs") - logContent := m.logViewport.View() - logRendered = logStyle.Render(logTitle + "\n" + logContent) + var sidebarRendered string + if m.showRightSidebar { + m.updateRightSidebar() + sidebarTitle := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Logs") + sidebarContent := m.rightSidebar.View() + sidebarRendered = sidebarStyle.Render(sidebarTitle + "\n" + sidebarContent) } // Split into lines menuLines := strings.Split(menuRendered, "\n") contentLines := strings.Split(contentRendered, "\n") - var logLines []string - if m.showLogs { - logLines = strings.Split(logRendered, "\n") + var sidebarLines []string + if m.showRightSidebar { + sidebarLines = strings.Split(sidebarRendered, "\n") } // Ensure same number of lines @@ -283,8 +282,8 @@ func (m *AppModel) View() string { if len(contentLines) > maxLines { maxLines = len(contentLines) } - if m.showLogs && len(logLines) > maxLines { - maxLines = len(logLines) + if m.showRightSidebar && len(sidebarLines) > maxLines { + maxLines = len(sidebarLines) } // Build bordered lines with proper alignment @@ -304,13 +303,13 @@ func (m *AppModel) View() string { line := borderStyle.Render("│") + menuLine + borderStyle.Render("│") + contentLine // Add log section if enabled - if m.showLogs { - logLine := "" - if i < len(logLines) { - logLine = logLines[i] + if m.showRightSidebar { + sidebarLine := "" + if i < len(sidebarLines) { + sidebarLine = sidebarLines[i] } - logLine = padRight(logLine, logWidth-2) - line = line + borderStyle.Render("│") + logLine + sidebarLine = padRight(sidebarLine, rightSidebarWidth-2) + line = line + borderStyle.Render("│") + sidebarLine } line = line + borderStyle.Render("│") @@ -349,14 +348,14 @@ func (m *AppModel) headerView() string { junction := "┬" var bottomLine string - if m.showLogs { + if m.showRightSidebar { // Calculate middle part width (content area) - middleWidth := innerWidth - menuWidth - logWidth + 2 + middleWidth := innerWidth - menuWidth - rightSidebarWidth + 2 if middleWidth < 0 { middleWidth = 0 } middlePart := strings.Repeat("─", middleWidth) - rightPart := strings.Repeat("─", logWidth-2) + rightPart := strings.Repeat("─", rightSidebarWidth-2) bottomLine = borderStyle.Render("├" + leftPart + junction + middlePart + junction + rightPart + "┤") } else { rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) @@ -366,14 +365,14 @@ func (m *AppModel) headerView() string { return lipgloss.JoinVertical(lipgloss.Left, topLine, titleLine, bottomLine) } -// updateLogView updates the log viewport with current log entries -func (m *AppModel) updateLogView() { +// updateRightSidebar updates the log viewport with current log entries +func (m *AppModel) updateRightSidebar() { if m.logBuffer == nil { return } entries := m.logBuffer.GetEntries() - var logContent strings.Builder + var sidebarContent strings.Builder // Render log entries for _, entry := range entries { @@ -389,19 +388,19 @@ func (m *AppModel) updateLogView() { levelStyle = m.styles.MutedTextStyle } - logLine := lipgloss.JoinHorizontal( + sidebarLine := lipgloss.JoinHorizontal( lipgloss.Left, m.styles.MutedTextStyle.Render("["+entry.Time+"] "), levelStyle.Render(entry.Level.CapitalString()+" "), m.styles.TextStyle.Render(entry.Message), ) - logContent.WriteString(logLine) - logContent.WriteString("\n") + sidebarContent.WriteString(sidebarLine) + sidebarContent.WriteString("\n") } - m.logViewport.SetContent(logContent.String()) + m.rightSidebar.SetContent(sidebarContent.String()) // Auto-scroll to bottom - m.logViewport.GotoBottom() + m.rightSidebar.GotoBottom() } // footerView renders the footer with shortcuts and status @@ -479,14 +478,14 @@ func (m *AppModel) footerView() string { junction := "┴" var topLine string - if m.showLogs { + if m.showRightSidebar { // Calculate middle part width (content area) - middleWidth := innerWidth - menuWidth - logWidth + 2 + middleWidth := innerWidth - menuWidth - rightSidebarWidth + 2 if middleWidth < 0 { middleWidth = 0 } middlePart := strings.Repeat("─", middleWidth) - rightPart := strings.Repeat("─", logWidth-2) + rightPart := strings.Repeat("─", rightSidebarWidth-2) topLine = borderStyle.Render("├" + leftPart + junction + middlePart + junction + rightPart + "┤") } else { rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) From 0a15eca7ee7a304b6ed1c62e500103075168f28a Mon Sep 17 00:00:00 2001 From: Evgheni Poleacov Date: Thu, 12 Feb 2026 16:59:26 +0200 Subject: [PATCH 08/11] 359: add dynamic footer shortcuts and fix layout styling --- internal/tui/app.go | 66 ++++++++++++++++++++++--------------- internal/tui/environment.go | 46 +++++++++----------------- 2 files changed, 55 insertions(+), 57 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 079e1aa..2e97e7d 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -33,6 +33,7 @@ type AppModel struct { tuiLogger *zap.SugaredLogger inSubView bool showRightSidebar bool + footerShortcuts string } // NewAppModel creates a new app model with the given items @@ -55,15 +56,16 @@ func NewAppModel(logger *zap.SugaredLogger) *AppModel { tuiLogger := CreateTUILogger(logBuffer) return &AppModel{ - list: l, - styles: styles, - quitting: false, - ready: false, - logger: logger, - logBuffer: logBuffer, - tuiLogger: tuiLogger, - inSubView: false, - showRightSidebar: false, + list: l, + styles: styles, + quitting: false, + ready: false, + logger: logger, + logBuffer: logBuffer, + tuiLogger: tuiLogger, + inSubView: false, + showRightSidebar: false, + footerShortcuts: "[a] Actions [/] Search [?] Help [q] Quit", } } @@ -141,8 +143,10 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.showRightSidebar = false m.envModel = nil m.selectedItem = "" - // Recalculate layout without logs + // Recalculate layout without sidebar m.viewport.Width = m.width - menuWidth - 1 + // Restore default shortcuts + m.footerShortcuts = "[a] Actions [/] Search [?] Help [q] Quit" return m, nil } case "q", "ctrl+c": @@ -159,6 +163,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Update viewport with environment view m.viewport.SetContent(m.envModel.View()) + // Update footer shortcuts from environment model + m.footerShortcuts = m.envModel.FooterShortcuts() } return m, tea.Batch(cmds...) @@ -187,7 +193,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, envCmd) } m.viewport.SetContent(m.envModel.View()) - // Recalculate layout with logs visible + // Set footer shortcuts from environment model + m.footerShortcuts = m.envModel.FooterShortcuts() + // Recalculate layout with sidebar visible m.viewport.Width = m.width - menuWidth - rightSidebarWidth m.rightSidebar.Width = rightSidebarWidth - 2 default: @@ -205,6 +213,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, envCmd) } m.viewport.SetContent(m.envModel.View()) + // Keep footer shortcuts in sync + m.footerShortcuts = m.envModel.FooterShortcuts() } else { // Update list var cmd tea.Cmd @@ -417,26 +427,28 @@ func (m *AppModel) footerView() string { Foreground(lipgloss.Color("#FFFDF5")). Padding(0, 1) - // Shortcuts style (left side with pink background) - shortcutsStyle := statusNugget. - Background(lipgloss.Color("#FF5F87")). + // Shortcuts style (left side with purple background) + shortcutsStyle := statusNugget.Copy(). + Background(lipgloss.Color("#A550DF")). MarginRight(1) // Namespace style (purple background) - namespaceStyle := statusNugget. - Background(lipgloss.Color("#A550DF")) + namespaceStyle := statusNugget.Copy(). + Background(lipgloss.Color("#6124DF")). + MarginLeft(1). + MarginRight(1) - // Release style (deep purple background) - releaseStyle := statusNugget. - Background(lipgloss.Color("#6124DF")) + // Release style (purple background) + releaseStyle := statusNugget.Copy(). + Background(lipgloss.Color("#6124DF")). + MarginRight(1) - // Version style - versionStyle := statusNugget. - Background(lipgloss.Color("#874BFD")) + // Version style (purple background) + versionStyle := statusNugget.Copy(). + Background(lipgloss.Color("#6124DF")) - // Build shortcuts - shortcuts := "[a] Actions [/] Search [?] Help [q] Quit" - shortcutsContent := shortcutsStyle.Render(shortcuts) + // Build shortcuts from dynamic field + shortcutsContent := shortcutsStyle.Render(m.footerShortcuts) // Status info versionNum := version.Version @@ -453,8 +465,8 @@ func (m *AppModel) footerView() string { Inherit(statusBarStyle). Padding(0, 1) - // Calculate remaining width for middle section - middleWidth := m.width - w(shortcutsContent) - w(namespaceContent) - w(releaseContent) - w(versionContent) + // Calculate remaining width for middle section (subtract 2 for outer borders) + middleWidth := m.width - 2 - w(shortcutsContent) - w(namespaceContent) - w(releaseContent) - w(versionContent) if middleWidth < 0 { middleWidth = 0 } diff --git a/internal/tui/environment.go b/internal/tui/environment.go index 0a0e439..571ffa8 100644 --- a/internal/tui/environment.go +++ b/internal/tui/environment.go @@ -232,6 +232,22 @@ func (m *EnvironmentModel) View() string { return content } +// FooterShortcuts returns the shortcuts for the footer based on current view +func (m *EnvironmentModel) FooterShortcuts() string { + switch m.currentView { + case ViewList: + return "[c] Create [d] Delete [enter] Details [r] Refresh [esc] Back [q] Quit" + case ViewCreate: + return "[tab] Navigate [enter] Submit [esc] Cancel [q] Quit" + case ViewDelete: + return "[y] Confirm [n] Cancel [esc] Back [q] Quit" + case ViewDetails: + return "[esc] Back [q] Quit" + default: + return "[esc] Back [q] Quit" + } +} + // viewList renders the environment list view func (m *EnvironmentModel) viewList() string { var b strings.Builder @@ -257,17 +273,10 @@ func (m *EnvironmentModel) viewList() string { if len(m.environments) == 0 { noEnvMsg := m.styles.MutedTextStyle.Render("No environments found. Press 'c' to create one.") b.WriteString(noEnvMsg) - b.WriteString("\n") } else { b.WriteString(m.table.View()) - b.WriteString("\n") } - // Help - helpStyle := m.styles.MutedTextStyle - help := "\n" + helpStyle.Render("c: create • d: delete • enter: details • r: refresh • esc: back") - b.WriteString(help) - return b.String() } @@ -298,14 +307,8 @@ func (m *EnvironmentModel) viewCreate() string { if m.err != nil { errMsg := m.styles.ErrorStyle.Render("✗ Error: " + m.err.Error()) b.WriteString(errMsg) - b.WriteString("\n\n") } - // Help - helpStyle := m.styles.MutedTextStyle - help := helpStyle.Render("tab/shift+tab: navigate • enter: submit • esc: cancel") - b.WriteString(help) - return b.String() } @@ -320,8 +323,6 @@ func (m *EnvironmentModel) viewDelete() string { if m.selectedEnv == nil { b.WriteString(m.styles.TextStyle.Render("No environment selected.")) - b.WriteString("\n\n") - b.WriteString(m.styles.MutedTextStyle.Render("Press 'esc' to go back.")) return b.String() } @@ -344,14 +345,8 @@ func (m *EnvironmentModel) viewDelete() string { if m.err != nil { errMsg := m.styles.ErrorStyle.Render("✗ Error: " + m.err.Error()) b.WriteString(errMsg) - b.WriteString("\n\n") } - // Help - helpStyle := m.styles.MutedTextStyle - help := helpStyle.Render("y: confirm delete • n/esc: cancel") - b.WriteString(help) - return b.String() } @@ -366,8 +361,6 @@ func (m *EnvironmentModel) viewDetails() string { if m.selectedEnv == nil { b.WriteString(m.styles.TextStyle.Render("No environment selected.")) - b.WriteString("\n\n") - b.WriteString(m.styles.MutedTextStyle.Render("Press 'esc' to go back.")) return b.String() } @@ -388,13 +381,6 @@ func (m *EnvironmentModel) viewDetails() string { statusValue := statusStyle.Render(m.selectedEnv.status) b.WriteString(fmt.Sprintf("%s %s\n", statusLabel, statusValue)) - b.WriteString("\n") - - // Help - helpStyle := m.styles.MutedTextStyle - help := helpStyle.Render("esc: back") - b.WriteString(help) - return b.String() } From ce5d66a38a929e67f29779b8b02ffa8a1b54acf4 Mon Sep 17 00:00:00 2001 From: Evgheni Poleacov Date: Thu, 12 Feb 2026 17:11:18 +0200 Subject: [PATCH 09/11] 359: fix content overflow by capping maxLines to viewport height --- internal/tui/app.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 2e97e7d..7a0c7f7 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -287,14 +287,8 @@ func (m *AppModel) View() string { sidebarLines = strings.Split(sidebarRendered, "\n") } - // Ensure same number of lines - maxLines := len(menuLines) - if len(contentLines) > maxLines { - maxLines = len(contentLines) - } - if m.showRightSidebar && len(sidebarLines) > maxLines { - maxLines = len(sidebarLines) - } + // Use viewport height as maxLines to prevent overflow + maxLines := m.viewport.Height // Build bordered lines with proper alignment var borderedLines []string From e9b23dbef0c61f659c79a9e27778dccc3aaf724d Mon Sep 17 00:00:00 2001 From: Evgheni Poleacov Date: Thu, 12 Feb 2026 21:55:15 +0200 Subject: [PATCH 10/11] 359: remove main menu subtitles --- internal/tui/app.go | 2 ++ internal/tui/menu.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 7a0c7f7..98fdaf2 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -42,6 +42,8 @@ func NewAppModel(logger *zap.SugaredLogger) *AppModel { // Create list with default delegate delegate := list.NewDefaultDelegate() + delegate.ShowDescription = false + delegate.SetSpacing(1) l := list.New(items, delegate, 0, 0) l.Title = "" l.SetShowStatusBar(false) diff --git a/internal/tui/menu.go b/internal/tui/menu.go index bc9c0df..0a3ee6b 100644 --- a/internal/tui/menu.go +++ b/internal/tui/menu.go @@ -15,7 +15,7 @@ type MenuItem struct { func (i MenuItem) Title() string { return i.title } // Description returns the menu item description (implements list.Item) -func (i MenuItem) Description() string { return i.description } +func (i MenuItem) Description() string { return "" } // FilterValue returns the filter value (implements list.Item) func (i MenuItem) FilterValue() string { return i.title } From 1bacf171cb97b0da9d37eb2e059325a1f2fe811c Mon Sep 17 00:00:00 2001 From: Evgheni Poleacov Date: Thu, 12 Feb 2026 22:34:24 +0200 Subject: [PATCH 11/11] 359: adjust menu width and clean up env form layout --- internal/tui/app.go | 2 +- internal/tui/environment.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 98fdaf2..ea026c6 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -12,7 +12,7 @@ import ( ) const ( - menuWidth = 30 + menuWidth = 19 // longest menu item "Configuration" (13) + 6 rightSidebarWidth = 50 ) diff --git a/internal/tui/environment.go b/internal/tui/environment.go index 571ffa8..8d0a2af 100644 --- a/internal/tui/environment.go +++ b/internal/tui/environment.go @@ -271,7 +271,7 @@ func (m *EnvironmentModel) viewList() string { // Environment table if len(m.environments) == 0 { - noEnvMsg := m.styles.MutedTextStyle.Render("No environments found. Press 'c' to create one.") + noEnvMsg := m.styles.MutedTextStyle.Render("No environments found.") b.WriteString(noEnvMsg) } else { b.WriteString(m.table.View()) @@ -300,7 +300,6 @@ func (m *EnvironmentModel) viewCreate() string { } else { b.WriteString(m.styles.MutedTextStyle.Render(m.createInputs[i].View())) } - b.WriteString("\n\n") } // Show error if any