// Package view provides TUI rendering functionality package view import ( "fmt" "strings" "github.com/charmbracelet/lipgloss" "github.com/jfraeys/fetch_ml/cmd/tui/internal/model" ) const ( headerfgLight = "#d35400" headerfgDark = "#ff9e64" activeBorderfgLight = "#3498db" activeBorderfgDark = "#7aa2f7" errorbgLight = "#fee" errorbgDark = "#633" errorfgLight = "#a00" errorfgDark = "#faa" titlefgLight = "#d35400" titlefgDark = "#ff9e64" statusfgLight = "#2e3440" statusfgDark = "#d8dee9" statusbgLight = "#e5e9f0" statusbgDark = "#2e3440" borderfgLight = "#d8dee9" borderfgDark = "#4c566a" helpfgLight = "#4c566a" helpfgDark = "#88c0d0" ) var ( docStyle = lipgloss.NewStyle().Margin(1, 2) activeBorderStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.AdaptiveColor{Light: activeBorderfgLight, Dark: activeBorderfgDark}). Padding(1, 2) errorStyle = lipgloss.NewStyle(). Background(lipgloss.AdaptiveColor{Light: errorbgLight, Dark: errorbgDark}). Foreground(lipgloss.AdaptiveColor{Light: errorfgLight, Dark: errorfgDark}). Padding(0, 1). Bold(true) titleStyle = (lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.AdaptiveColor{Light: titlefgLight, Dark: titlefgDark}). MarginBottom(1)) statusStyle = (lipgloss.NewStyle(). Background(lipgloss.AdaptiveColor{Light: statusbgLight, Dark: statusbgDark}). Foreground(lipgloss.AdaptiveColor{Light: statusfgLight, Dark: statusfgDark}). Padding(0, 1)) borderStyle = (lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.AdaptiveColor{Light: borderfgLight, Dark: borderfgDark}). Padding(0, 1)) helpStyle = (lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: helpfgLight, Dark: helpfgDark})) ) // Render renders the TUI view func Render(m model.State) string { if m.Width == 0 { return "Loading..." } // Title title := titleStyle.Width(m.Width - 4).Render("๐Ÿค– ML Experiment Manager") // Left panel - Job list (30% width) leftWidth := int(float64(m.Width) * 0.3) leftPanel := getJobListPanel(m, leftWidth) // Right panel - Dynamic content (70% width) rightWidth := m.Width - leftWidth - 4 rightPanel := getRightPanel(m, rightWidth) // Main content main := lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, rightPanel) // Status bar statusBar := getStatusBar(m) // Error bar (if present) var errorBar string if m.ErrorMsg != "" { errorBar = errorStyle.Width(m.Width - 4).Render("โš  Error: " + m.ErrorMsg) } // Help view (toggleable) var helpView string if m.ShowHelp { helpView = helpStyle.Width(m.Width-4). Padding(1, 2). Render(helpText(m)) } // Quick help bar quickHelp := helpStyle.Width(m.Width - 4).Render(getQuickHelp(m)) // Compose final layout parts := []string{title, main, statusBar} if errorBar != "" { parts = append(parts, errorBar) } if helpView != "" { parts = append(parts, helpView) } parts = append(parts, quickHelp) return docStyle.Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) } func getJobListPanel(m model.State, width int) string { style := borderStyle if m.ActiveView == model.ViewModeJobs { style = activeBorderStyle } // Ensure the job list has proper dimensions to prevent rendering issues // Note: We can't modify the model here as it's passed by value, // but the View() method of list.Model uses its internal state. // Ideally, the controller should have set the size. // For now, we assume the controller handles resizing or we act on a copy. // But list.Model.SetSize modifies the model. // Since we receive 'm' by value, modifications to m.JobList won't persist. // However, we need to render it with the correct size. // So we can modify our local copy 'm'. h, v := style.GetFrameSize() m.JobList.SetSize(width-h, m.Height-v-4) // Adjust height for title/help/status // Custom empty state if len(m.JobList.Items()) == 0 { return style.Width(width - h).Render( lipgloss.JoinVertical(lipgloss.Left, m.JobList.Styles.Title.Render(m.JobList.Title), "\n No jobs found.", " Press 't' to queue.", ), ) } return style.Width(width - h).Render(m.JobList.View()) } func getRightPanel(m model.State, width int) string { var content string var viewTitle string style := borderStyle switch m.ActiveView { case model.ViewModeGPU: style = activeBorderStyle viewTitle = "๐ŸŽฎ GPU Status" content = m.GpuView.View() case model.ViewModeContainer: style = activeBorderStyle viewTitle = "๐Ÿณ Container Status" content = m.ContainerView.View() case model.ViewModeQueue: style = activeBorderStyle viewTitle = "โณ Task Queue" content = m.QueueView.View() case model.ViewModeSettings: style = activeBorderStyle viewTitle = "โš™๏ธ Settings" content = m.SettingsView.View() case model.ViewModeExperiments: style = activeBorderStyle viewTitle = "๐Ÿงช Experiments" content = m.ExperimentsView.View() case model.ViewModeJobs: style = activeBorderStyle viewTitle = "๐Ÿ“‹ Job Details" content = m.JobList.View() case model.ViewModeDatasets: style = activeBorderStyle viewTitle = "๐Ÿ“ฆ Datasets" content = m.DatasetView.View() case model.ViewModeNarrative: style = activeBorderStyle viewTitle = "๐Ÿ“Š Research Context" narrativeView := &NarrativeView{Width: width, Height: m.Height} content = narrativeView.View(m.SelectedJob) case model.ViewModeTeam: style = activeBorderStyle viewTitle = "๐Ÿ‘ฅ Team Jobs" content = "Team collaboration view - shows jobs from all team members\n\n(Requires API: GET /api/jobs?all_users=true)" case model.ViewModeExperimentHistory: style = activeBorderStyle viewTitle = "๐Ÿ“œ Experiment History" content = m.ExperimentHistoryView.View() case model.ViewModeConfig: style = activeBorderStyle viewTitle = "โš™๏ธ Config" content = m.ConfigView.View() case model.ViewModeLogs: style = activeBorderStyle viewTitle = "๐Ÿ“œ Logs" content = m.LogsView.View() default: viewTitle = "๐Ÿ“Š System Overview" content = getOverviewPanel(m) } header := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.AdaptiveColor{Light: headerfgLight, Dark: headerfgDark}). Render(viewTitle) h, _ := style.GetFrameSize() return style.Width(width - h).Render(header + "\n\n" + content) } func getOverviewPanel(m model.State) string { var sections []string sections = append(sections, "๐ŸŽฎ GPU\n"+strings.Repeat("โ”€", 40)) sections = append(sections, m.GpuView.View()) sections = append(sections, "\n๐Ÿณ Containers\n"+strings.Repeat("โ”€", 40)) sections = append(sections, m.ContainerView.View()) return strings.Join(sections, "\n") } func getStatusBar(m model.State) string { spinnerStr := m.Spinner.View() if !m.IsLoading { if m.ShowHelp { spinnerStr = "?" } else { spinnerStr = "โ—" } } statusText := m.Status if m.ShowHelp { statusText = "Press 'h' to hide help" } // Add refresh rate indicator refreshInfo := "" if m.RefreshRate > 0 { refreshInfo = fmt.Sprintf(" | %.0fms", m.RefreshRate) } return statusStyle.Width(m.Width - 4).Render(spinnerStr + " " + statusText + refreshInfo) } func helpText(m model.State) string { if m.ActiveView == model.ViewModeSettings { return `โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— โ•‘ Settings Shortcuts โ•‘ โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•ฃ โ•‘ Navigation โ•‘ โ•‘ j/k, โ†‘/โ†“ : Move selection โ•‘ โ•‘ Enter : Edit / Save โ•‘ โ•‘ Esc : Exit Settings โ•‘ โ•‘ โ•‘ โ•‘ General โ•‘ โ•‘ h or ? : Toggle this help q/Ctrl+C : Quit โ•‘ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•` } return `โ•”โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•— โ•‘ Keyboard Shortcuts โ•‘ โ• โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•ฃ โ•‘ Navigation โ•‘ โ•‘ j/k, โ†‘/โ†“ : Move selection / : Filter jobs โ•‘ โ•‘ 1 : Job list view 2 : Datasets view โ•‘ โ•‘ 3 : Experiments view q : Queue view โ•‘ โ•‘ g : GPU view o : Container view โ•‘ โ•‘ s : Settings view n : Narrative view โ•‘ โ•‘ m : Team view e : Experiment history โ•‘ โ•‘ c : Config view l : Logs (job stream) โ•‘ โ•‘ E : Export job @ : Filter by team โ•‘ โ•‘ โ•‘ โ•‘ Actions โ•‘ โ•‘ t : Queue job a : Queue w/ args โ•‘ โ•‘ x : Cancel task d : Delete pending โ•‘ โ•‘ f : Mark as failed r : Refresh all โ•‘ โ•‘ G : Refresh GPU only โ•‘ โ•‘ โ•‘ โ•‘ General โ•‘ โ•‘ h or ? : Toggle this help Ctrl+C : Quit โ•‘ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•` } func getQuickHelp(m model.State) string { if m.ActiveView == model.ViewModeSettings { return " โ†‘/โ†“:move enter:select esc:exit settings Ctrl+C:quit" } return " h:help 1:jobs 2:datasets 3:experiments q:queue g:gpu o:containers " + "n:narrative m:team e:history c:config l:logs E:export @:filter s:settings t:trigger r:refresh Ctrl+C:quit" }