package main import ( "fmt" "io" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type tuiTheme struct { outer lipgloss.Style headerBar lipgloss.Style badge lipgloss.Style chrome lipgloss.Style title lipgloss.Style subtitle lipgloss.Style sectionTitle lipgloss.Style body lipgloss.Style muted lipgloss.Style footer lipgloss.Style panel lipgloss.Style panelAccent lipgloss.Style row lipgloss.Style rowSelected lipgloss.Style rowNumber lipgloss.Style rowNumberOn lipgloss.Style rowKey lipgloss.Style rowKeySelected lipgloss.Style rowTitle lipgloss.Style rowTitleOn lipgloss.Style rowHint lipgloss.Style rowHintActive lipgloss.Style statusInfo lipgloss.Style statusWarn lipgloss.Style statusSuccess lipgloss.Style inputLabel lipgloss.Style inputBox lipgloss.Style } func newTUITheme() tuiTheme { border := lipgloss.Color("#5A6772") borderSoft := lipgloss.Color("#31414A") text := lipgloss.Color("#ECE4D8") muted := lipgloss.Color("#A49788") accent := lipgloss.Color("#D9733F") accentSoft := lipgloss.Color("#F2D2B5") panel := lipgloss.Color("#161C20") panelAlt := lipgloss.Color("#212A30") panelLift := lipgloss.Color("#2B363D") info := lipgloss.Color("#86B7C6") warn := lipgloss.Color("#D8AB63") success := lipgloss.Color("#91B57F") return tuiTheme{ outer: lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(border). Padding(1, 2), headerBar: lipgloss.NewStyle(). Foreground(borderSoft), chrome: lipgloss.NewStyle(). Foreground(muted), badge: lipgloss.NewStyle(). Foreground(panel). Background(accentSoft). Bold(true). Padding(0, 1), subtitle: lipgloss.NewStyle(). Foreground(muted), title: lipgloss.NewStyle(). Foreground(text). Bold(true), sectionTitle: lipgloss.NewStyle(). Foreground(accentSoft). Bold(true). Border(lipgloss.NormalBorder(), false, false, true, false). BorderForeground(borderSoft). PaddingBottom(0). MarginBottom(1), body: lipgloss.NewStyle(). Foreground(text), muted: lipgloss.NewStyle(). Foreground(muted), footer: lipgloss.NewStyle(). Foreground(muted). Border(lipgloss.NormalBorder(), true, false, false, false). BorderForeground(borderSoft). PaddingTop(1). MarginTop(1), panel: lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, false, true). BorderForeground(borderSoft). PaddingLeft(1). MarginBottom(1), panelAccent: lipgloss.NewStyle(). Border(lipgloss.ThickBorder(), false, false, false, true). BorderForeground(accent). PaddingLeft(1). MarginBottom(1), row: lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, false, true). BorderForeground(panelLift). Padding(0, 1). MarginBottom(1), rowSelected: lipgloss.NewStyle(). Border(lipgloss.ThickBorder(), false, false, false, true). BorderForeground(accent). Background(panel). Padding(0, 1). MarginBottom(1), rowNumber: lipgloss.NewStyle(). Foreground(muted), rowNumberOn: lipgloss.NewStyle(). Foreground(accentSoft). Bold(true), rowKey: lipgloss.NewStyle(). Foreground(accent). Bold(true). Background(panelAlt). Padding(0, 1), rowKeySelected: lipgloss.NewStyle(). Foreground(accentSoft). Background(panelLift). Bold(true). Padding(0, 1), rowTitle: lipgloss.NewStyle(). Foreground(text). Bold(true), rowTitleOn: lipgloss.NewStyle(). Foreground(text). Bold(true), rowHint: lipgloss.NewStyle(). Foreground(muted), rowHintActive: lipgloss.NewStyle(). Foreground(text), statusInfo: lipgloss.NewStyle(). Foreground(info). Bold(true), statusWarn: lipgloss.NewStyle(). Foreground(warn). Bold(true), statusSuccess: lipgloss.NewStyle(). Foreground(success). Bold(true), inputLabel: lipgloss.NewStyle(). Foreground(accentSoft). Bold(true), inputBox: lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(border). Padding(0, 1). Background(panelAlt), } } func clampFrameWidth(windowWidth int) int { const fallback = 84 if windowWidth <= 0 { return fallback } if windowWidth <= 44 { return windowWidth } if windowWidth > 92 { return 92 } return windowWidth - 2 } func innerWidth(frameWidth int) int { if frameWidth <= 10 { return frameWidth } return frameWidth - 6 } func (t tuiTheme) renderFrame(width int, title, subtitle, body, footer string) string { frameWidth := clampFrameWidth(width) contentWidth := innerWidth(frameWidth) placeWidth := width if placeWidth <= 0 { placeWidth = frameWidth } headerLeft := lipgloss.JoinHorizontal(lipgloss.Top, t.badge.Render("SAMBA CONFIGER"), " ", t.chrome.Render("share editor")) headerRule := t.headerBar.Render(strings.Repeat("─", max(0, contentWidth-lipgloss.Width(headerLeft)-1))) header := lipgloss.JoinHorizontal(lipgloss.Center, headerLeft, " ", headerRule) sections := []string{header, t.title.Width(contentWidth).Render(title)} if subtitle != "" { sections = append(sections, t.subtitle.Width(contentWidth).Render(subtitle)) } if body != "" { sections = append(sections, body) } if footer != "" { sections = append(sections, t.footer.Width(contentWidth).Render(footer)) } return lipgloss.PlaceHorizontal(placeWidth, lipgloss.Center, t.outer.Width(frameWidth).Render(strings.Join(sections, "\n\n"))) } func (t tuiTheme) renderSection(width int, title string, lines []string) string { if len(lines) == 0 { return "" } contentWidth := innerWidth(clampFrameWidth(width)) rendered := make([]string, 0, len(lines)+1) if title != "" { rendered = append(rendered, t.sectionTitle.Width(contentWidth).Render(title)) } for _, line := range lines { rendered = append(rendered, t.panel.Width(contentWidth).Render(t.body.Width(max(1, contentWidth-2)).Render(line))) } return strings.Join(rendered, "\n") } func (t tuiTheme) renderMessage(width int, kind, text string) string { label := t.statusInfo switch kind { case "warn": label = t.statusWarn case "success": label = t.statusSuccess } return t.panel.Width(innerWidth(clampFrameWidth(width))).Render( label.Render(strings.ToUpper(kind)) + " " + t.body.Render(text), ) } func max(a, b int) int { if a > b { return a } return b } type menuOption struct { Key string Value string Label string Description string } type menuModel struct { theme tuiTheme title string subtitle string intro []string options []menuOption cursor int choice string cancel bool width int } func (m menuModel) Init() tea.Cmd { return nil } func (m menuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width case tea.KeyMsg: key := strings.ToLower(msg.String()) switch key { case "ctrl+c", "esc": m.cancel = true return m, tea.Quit case "up", "k": if len(m.options) > 0 { m.cursor = (m.cursor - 1 + len(m.options)) % len(m.options) } case "down", "j": if len(m.options) > 0 { m.cursor = (m.cursor + 1) % len(m.options) } case "enter": if len(m.options) > 0 { m.choice = m.options[m.cursor].Value return m, tea.Quit } default: for i, option := range m.options { if key == strings.ToLower(option.Key) || key == fmt.Sprintf("%d", i+1) { m.cursor = i m.choice = option.Value return m, tea.Quit } } } } return m, nil } func (m menuModel) View() string { width := clampFrameWidth(m.width) if width == 0 { width = clampFrameWidth(84) } contentWidth := innerWidth(width) rows := make([]string, 0, len(m.options)) for i, option := range m.options { rowStyle := m.theme.row keyStyle := m.theme.rowKey titleStyle := m.theme.rowTitle hintStyle := m.theme.rowHint numberStyle := m.theme.rowNumber prefix := numberStyle.Render(fmt.Sprintf("%02d", i+1)) if i == m.cursor { rowStyle = m.theme.rowSelected keyStyle = m.theme.rowKeySelected titleStyle = m.theme.rowTitleOn hintStyle = m.theme.rowHintActive numberStyle = m.theme.rowNumberOn prefix = numberStyle.Render(">>") } top := lipgloss.JoinHorizontal( lipgloss.Center, prefix, " ", keyStyle.Render(strings.ToUpper(option.Key)), " ", titleStyle.Render(option.Label), ) rowLines := []string{top} if option.Description != "" { rowLines = append(rowLines, hintStyle.Render(option.Description)) } rows = append(rows, rowStyle.Width(contentWidth).Render(strings.Join(rowLines, "\n"))) } bodyParts := []string{} if len(m.intro) > 0 { bodyParts = append(bodyParts, m.theme.renderSection(width, "Context", m.intro)) } bodyParts = append(bodyParts, m.theme.sectionTitle.Width(contentWidth).Render("Actions")) bodyParts = append(bodyParts, strings.Join(rows, "\n")) return m.theme.renderFrame( m.width, m.title, m.subtitle, strings.Join(bodyParts, "\n\n"), "enter select • j/k or arrows move • letter/number picks instantly • esc cancel", ) } type textPromptModel struct { theme tuiTheme title string subtitle string label string defaultValue string input textinput.Model value string cancel bool width int } func newTextPromptModel(theme tuiTheme, title, subtitle, label, defaultValue string) textPromptModel { input := textinput.New() input.Prompt = "" input.Placeholder = defaultValue input.Focus() input.CharLimit = 512 input.Width = 48 return textPromptModel{ theme: theme, title: title, subtitle: subtitle, label: label, defaultValue: defaultValue, input: input, } } func (m textPromptModel) Init() tea.Cmd { return textinput.Blink } func (m textPromptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width available := innerWidth(clampFrameWidth(m.width)) - 4 if available < 16 { available = 16 } m.input.Width = available case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": m.cancel = true return m, tea.Quit case "enter": value := strings.TrimSpace(m.input.Value()) if value == "" { value = m.defaultValue } m.value = value return m, tea.Quit } } var cmd tea.Cmd m.input, cmd = m.input.Update(msg) return m, cmd } func (m textPromptModel) View() string { width := clampFrameWidth(m.width) contentWidth := innerWidth(width) lines := []string{m.theme.inputLabel.Width(contentWidth).Render(m.label)} lines = append(lines, m.theme.panelAccent.Width(contentWidth).Render(m.theme.inputBox.Width(max(8, contentWidth-2)).Render(m.input.View()))) if m.defaultValue != "" { lines = append(lines, m.theme.muted.Width(contentWidth).Render("Enter keeps: "+m.defaultValue)) } return m.theme.renderFrame( m.width, m.title, m.subtitle, strings.Join(lines, "\n\n"), "enter confirm • esc cancel", ) } func runMenuPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle string, intro []string, options []menuOption) (string, error) { model := menuModel{ theme: theme, title: title, subtitle: subtitle, intro: intro, options: options, } result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run() if err != nil { return "", err } final := result.(menuModel) if final.cancel { return "", ErrCancelled } return final.choice, nil } func runTextPrompt(in io.Reader, out io.Writer, theme tuiTheme, title, subtitle, label, defaultValue string) (string, error) { model := newTextPromptModel(theme, title, subtitle, label, defaultValue) result, err := tea.NewProgram(model, tea.WithInput(in), tea.WithOutput(out)).Run() if err != nil { return "", err } final := result.(textPromptModel) if final.cancel { return "", ErrCancelled } return final.value, nil }