Files
bifrost/cli/internal/ui/tui/chooser.go
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

1058 lines
28 KiB
Go

package tui
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"time"
textInput "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/maximhq/bifrost/cli/internal/ui/logo"
)
const issuesURL = "https://github.com/maximhq/bifrost/issues/new"
const repoURL = "https://github.com/maximhq/bifrost"
const docsURL = "https://docs.getbifrost.ai/quickstart/cli/getting-started"
// HarnessOption represents a selectable coding harness (e.g. Claude Code, Codex)
// with its installation status.
type HarnessOption struct {
ID string
Label string
Version string
Installed bool
SupportsWorktree bool
SupportsModelOverride bool // true when the harness accepts an external model setting
}
// ChooserConfig holds the initial values and callbacks for the interactive chooser TUI.
type ChooserConfig struct {
Version string
Commit string
ConfigSrc string
Message string
UpdateVersion string
BaseURL string
VirtualKey string
Harness string
Model string
Worktree string
Harnesses []HarnessOption
AfterSession bool // true when returning from a harness session; blocks input until ready
ReservedRows int // rows reserved by the tab bar; subtracted from the available height
TabBarLine func() string // returns the current tab bar content; rendered as the last line
FetchModels func(ctx context.Context, baseURL, virtualKey string) ([]string, error)
Notify func(message string, isError bool)
Input io.Reader // optional stdin override; when nil, os.Stdin is used
}
// ChooserResult holds the user's selections after the chooser TUI completes.
type ChooserResult struct {
Quit bool
BackToTabs bool // true when the user pressed Ctrl+B to return to tab command mode
UpdateRequested bool
InstallHarness bool // true when user selected a harness that needs installation
BaseURL string
VirtualKey string
Harness string
Model string
Worktree string
}
type chooserPhase int
const (
phaseBaseURL chooserPhase = iota
phaseVirtualKey
phaseHarness
phaseModel
phaseWorktree
phaseSummary
)
type modelsMsg struct {
models []string
err error
}
type warmupDoneMsg struct{}
type chooserModel struct {
cfg ChooserConfig
phase chooserPhase
quit bool
backToTabs bool
done bool
installHarness bool
updateRequested bool
returnToSummary bool
width int
height int
baseInput textInput.Model
vkInput textInput.Model
worktreeInput textInput.Model
harnessIdx int
modelIdx int
models []string
filterInput textInput.Model
filtered []int // indices into models
loading bool
loadErr string
message string
warming bool // true while ignoring input after session ended
plainLayout bool // conservative layout for terminals with flaky full-screen rendering
}
// RunChooser launches the interactive multi-phase chooser TUI. It walks the user
// through selecting a base URL, virtual key, harness, and model, then returns
// the collected selections. Returns ChooserResult with Quit=true if the user aborts.
func RunChooser(cfg ChooserConfig) (ChooserResult, error) {
m := newChooserModel(cfg)
input := cfg.Input
if input == nil {
input = os.Stdin
}
p := tea.NewProgram(
m,
tea.WithInput(input),
tea.WithOutput(os.Stdout),
)
final, err := p.Run()
if err != nil {
return ChooserResult{}, err
}
fm, ok := final.(chooserModel)
if !ok {
return ChooserResult{}, fmt.Errorf("unexpected model type from TUI")
}
if fm.backToTabs {
return ChooserResult{BackToTabs: true}, nil
}
if fm.updateRequested {
return ChooserResult{UpdateRequested: true}, nil
}
if fm.quit {
return ChooserResult{Quit: true}, nil
}
return ChooserResult{
InstallHarness: fm.installHarness,
BaseURL: strings.TrimSpace(fm.baseInput.Value()),
VirtualKey: strings.TrimSpace(fm.vkInput.Value()),
Harness: fm.currentHarness().ID,
Model: strings.TrimSpace(fm.currentModel()),
Worktree: strings.TrimSpace(fm.worktreeInput.Value()),
}, nil
}
// newChooserModel initializes the chooser BubbleTea model with text inputs
// and pre-populates fields from the config. Skips completed phases when
// values are already provided.
func newChooserModel(cfg ChooserConfig) chooserModel {
base := textInput.New()
base.Placeholder = "http://localhost:8080"
base.Prompt = ""
base.SetValue(strings.TrimSpace(cfg.BaseURL))
base.Focus()
base.CharLimit = 512
vk := textInput.New()
vk.Placeholder = "optional (x-bf-vk)"
vk.Prompt = ""
vk.SetValue(strings.TrimSpace(cfg.VirtualKey))
vk.Blur()
vk.CharLimit = 512
wt := textInput.New()
wt.Placeholder = "optional worktree name"
wt.Prompt = ""
wt.SetValue(strings.TrimSpace(cfg.Worktree))
wt.Blur()
wt.CharLimit = 256
filter := textInput.New()
filter.Placeholder = "type to search models..."
filter.Prompt = "> "
filter.Blur()
filter.CharLimit = 128
hIdx := 0
for i, h := range cfg.Harnesses {
if h.ID == strings.TrimSpace(cfg.Harness) {
hIdx = i
break
}
}
m := chooserModel{
cfg: cfg,
phase: phaseBaseURL,
baseInput: base,
vkInput: vk,
worktreeInput: wt,
harnessIdx: hIdx,
filterInput: filter,
message: strings.TrimSpace(cfg.Message),
plainLayout: prefersPlainChooserLayout(),
}
if strings.TrimSpace(cfg.BaseURL) != "" {
m.phase = phaseHarness
m.baseInput.Blur()
}
if strings.TrimSpace(cfg.Harness) != "" && strings.TrimSpace(cfg.Model) != "" && strings.TrimSpace(cfg.BaseURL) != "" {
m.phase = phaseSummary
m.models = []string{strings.TrimSpace(cfg.Model)}
m.modelIdx = 0
}
if cfg.AfterSession {
m.warming = true
}
return m
}
// Init implements tea.Model.
func (m chooserModel) Init() tea.Cmd {
var cmds []tea.Cmd
if msg := strings.TrimSpace(m.message); msg != "" && m.cfg.Notify != nil {
cmds = append(cmds, func() tea.Msg {
m.cfg.Notify(msg, false)
return nil
})
}
if m.warming {
cmds = append(cmds, tea.Tick(10*time.Millisecond, func(time.Time) tea.Msg {
return warmupDoneMsg{}
}))
}
return tea.Batch(cmds...)
}
// Update implements tea.Model. It handles keyboard input for all chooser phases:
// base URL entry, virtual key entry, harness selection, model search/selection,
// worktree name entry, and launch summary.
func (m chooserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case warmupDoneMsg:
m.warming = false
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height - m.cfg.ReservedRows
if m.height < 10 {
m.height = 10
}
return m, tea.ClearScreen
case tea.KeyMsg:
if m.warming {
return m, nil
}
s := msg.String()
if s == "ctrl+c" {
m.quit = true
return m, tea.Quit
}
if s == "ctrl+b" {
m.backToTabs = true
return m, tea.Quit
}
canTriggerUpdate := strings.TrimSpace(m.cfg.UpdateVersion) != "" &&
m.phase != phaseBaseURL &&
m.phase != phaseVirtualKey &&
m.phase != phaseModel &&
m.phase != phaseWorktree
if canTriggerUpdate && (s == "y" || s == "Y") {
m.updateRequested = true
return m, tea.Quit
}
// Only handle 'q' as quit when not in a text input phase
if s == "q" && m.phase != phaseBaseURL && m.phase != phaseVirtualKey && m.phase != phaseModel && m.phase != phaseWorktree {
m.quit = true
return m, tea.Quit
}
switch m.phase {
case phaseBaseURL:
if s == "enter" {
if strings.TrimSpace(m.baseInput.Value()) == "" {
m.notify("base URL is required", true)
return m, nil
}
m.baseInput.Blur()
if m.returnToSummary {
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
m.phase = phaseVirtualKey
m.vkInput.Focus()
return m, nil
}
if s == "esc" && m.returnToSummary {
m.returnToSummary = false
m.baseInput.Blur()
m.phase = phaseSummary
return m, nil
}
var cmd tea.Cmd
m.baseInput, cmd = m.baseInput.Update(msg)
return m, cmd
case phaseVirtualKey:
if s == "enter" {
m.vkInput.Blur()
if m.returnToSummary {
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
m.phase = phaseHarness
return m, nil
}
if s == "esc" {
m.vkInput.Blur()
if m.returnToSummary {
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
m.phase = phaseBaseURL
m.baseInput.Focus()
return m, nil
}
if s == "f1" {
baseURL := strings.TrimSpace(m.baseInput.Value())
if baseURL != "" {
openBrowser(baseURL)
m.notify("opened bifrost dashboard", false)
}
return m, nil
}
var cmd tea.Cmd
m.vkInput, cmd = m.vkInput.Update(msg)
return m, cmd
case phaseHarness:
if s == "up" || s == "k" {
if m.harnessIdx > 0 {
m.harnessIdx--
}
return m, nil
}
if s == "down" || s == "j" {
if m.harnessIdx < len(m.cfg.Harnesses)-1 {
m.harnessIdx++
}
return m, nil
}
if s == "enter" {
selected := m.currentHarness()
if !selected.Installed {
m.installHarness = true
return m, tea.Quit
}
if m.returnToSummary {
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
m.phase = phaseModel
m.loading = true
m.loadErr = ""
m.models = nil
m.filtered = nil
m.filterInput.SetValue("")
return m, m.fetchModelsCmd()
}
if s == "esc" {
if m.returnToSummary {
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
m.phase = phaseVirtualKey
m.vkInput.Focus()
return m, nil
}
case phaseModel:
if m.loading {
if s == "esc" {
if m.returnToSummary {
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
m.phase = phaseHarness
return m, nil
}
return m, nil
}
if s == "esc" {
m.filterInput.SetValue("")
m.filterInput.Blur()
m.filtered = nil
if m.returnToSummary {
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
m.phase = phaseHarness
return m, nil
}
if s == "up" {
visible := m.visibleModels()
if m.modelIdx > 0 {
m.modelIdx--
} else if len(visible) > 0 {
m.modelIdx = len(visible) - 1
}
return m, nil
}
if s == "down" {
visible := m.visibleModels()
if m.modelIdx < len(visible)-1 {
m.modelIdx++
} else {
m.modelIdx = 0
}
return m, nil
}
if s == "enter" {
model := m.currentModel()
if model == "" {
// If filter text is non-empty, use it as manual model name
ft := strings.TrimSpace(m.filterInput.Value())
if ft != "" {
model = ft
} else {
m.notify("select a model", true)
return m, nil
}
}
// Pin the selected model so the summary always shows it correctly
m.models = []string{model}
m.modelIdx = 0
m.filtered = nil
m.filterInput.SetValue("")
m.filterInput.Blur()
m.phase = phaseSummary
return m, nil
}
// All other keys go to the filter input
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
query := strings.ToLower(strings.TrimSpace(m.filterInput.Value()))
if query == "" {
m.filtered = nil
} else {
terms := strings.Fields(query)
var indices []int
for i, model := range m.models {
lower := strings.ToLower(model)
match := true
for _, t := range terms {
if !strings.Contains(lower, t) {
match = false
break
}
}
if match {
indices = append(indices, i)
}
}
m.filtered = indices
}
m.modelIdx = 0
return m, cmd
case phaseWorktree:
if s == "enter" {
m.worktreeInput.Blur()
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
if s == "esc" {
m.worktreeInput.Blur()
m.returnToSummary = false
m.phase = phaseSummary
return m, nil
}
var cmd tea.Cmd
m.worktreeInput, cmd = m.worktreeInput.Update(msg)
return m, cmd
case phaseSummary:
switch s {
case "enter":
m.done = true
return m, tea.Quit
case "u":
m.phase = phaseBaseURL
m.returnToSummary = true
m.baseInput.Focus()
return m, nil
case "v":
m.phase = phaseVirtualKey
m.returnToSummary = true
m.vkInput.Focus()
return m, nil
case "w":
if m.currentHarness().SupportsWorktree {
m.phase = phaseWorktree
m.returnToSummary = true
m.worktreeInput.Focus()
return m, nil
}
case "h":
m.phase = phaseHarness
m.returnToSummary = true
return m, nil
case "m":
if !m.currentHarness().SupportsModelOverride {
m.notify(m.currentHarness().Label+" manages its own model selection", true)
return m, nil
}
m.phase = phaseModel
m.returnToSummary = true
m.loading = true
return m, m.fetchModelsCmd()
case "d":
baseURL := strings.TrimSpace(m.baseInput.Value())
if baseURL != "" {
openBrowser(baseURL)
m.notify("opened bifrost dashboard", false)
}
return m, nil
case "r":
openBrowser(docsURL)
m.notify("opened docs", false)
return m, nil
case "i":
openBrowser(issuesURL)
m.notify("opened GitHub issues", false)
return m, nil
case "s":
openBrowser(repoURL)
m.notify("opened GitHub repo", false)
return m, nil
case "esc":
m.quit = true
return m, tea.Quit
}
}
case modelsMsg:
m.loading = false
if msg.err != nil {
m.loadErr = msg.err.Error()
m.notify(m.loadErr, true)
m.models = nil
} else {
m.models = msg.models
if len(m.models) == 0 {
m.loadErr = "no models found \u2014 type a model name manually"
m.notify(m.loadErr, false)
}
}
m.modelIdx = 0
m.filtered = nil
m.filterInput.SetValue("")
m.filterInput.Focus()
}
return m, nil
}
// View implements tea.Model. It renders the current phase of the chooser TUI.
func (m chooserModel) View() string {
hint := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
label := lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
accent := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("28"))
cyan := lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
logoColor := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
w := m.width
if w == 0 {
w = 80
}
h := m.height
if h == 0 {
h = 24
}
// Build logo block (gray/white)
logoBlock := logoColor.Render(logo.Render(w))
// Meta line
meta := hint.Render(fmt.Sprintf("%s (%s) config=%s", m.cfg.Version, m.cfg.Commit, m.cfg.ConfigSrc))
// Build the phase content as two parts: a centered title and a left-aligned body
var title string
var body strings.Builder
var footer string
if m.message != "" && m.cfg.Notify == nil {
body.WriteString(hint.Render(m.message))
body.WriteString("\n\n")
}
if updateVersion := strings.TrimSpace(m.cfg.UpdateVersion); updateVersion != "" {
body.WriteString(accent.Render("Update available: "))
body.WriteString(hint.Render("bifrost " + updateVersion + " "))
body.WriteString(accent.Render("press y to update now"))
body.WriteString("\n\n")
}
if m.loadErr != "" && m.cfg.Notify == nil {
body.WriteString(errorStyle.Render(m.loadErr))
body.WriteString("\n\n")
}
switch m.phase {
case phaseBaseURL:
title = accent.Render("Base URL (Bifrost Base URL)")
body.WriteString(m.baseInput.View())
if m.returnToSummary {
footer = hint.Render("enter: update esc: cancel")
} else {
footer = hint.Render("enter: continue ctrl+c: quit")
}
case phaseVirtualKey:
title = accent.Render("Virtual Key") + label.Render(" (optional)")
body.WriteString(m.vkInput.View())
if m.returnToSummary {
footer = hint.Render("enter: update esc: cancel f1: open dashboard")
} else {
footer = hint.Render("enter: continue esc: back f1: open dashboard")
}
case phaseHarness:
title = accent.Render("Choose Harness")
body.WriteString("\n")
for i, ho := range m.cfg.Harnesses {
cursor := " "
style := label
if i == m.harnessIdx {
cursor = accent.Render("> ")
style = lipgloss.NewStyle().Bold(true)
}
status := cyan.Render("installed")
if !ho.Installed {
status = hint.Render("not installed")
}
ver := ""
if ho.Version != "" {
ver = hint.Render(" " + ho.Version)
}
fmt.Fprintf(&body, "%s%s%s %s\n", cursor, style.Render(ho.Label), ver, status)
}
if m.returnToSummary {
footer = hint.Render("up/down: move enter: select esc: cancel")
} else {
footer = hint.Render("up/down: move enter: select esc: back")
}
case phaseModel:
title = accent.Render("Model")
if m.loading {
body.WriteString(hint.Render("loading models from /v1/models..."))
footer = hint.Render("esc: back")
} else {
body.WriteString(m.filterInput.View())
body.WriteString("\n\n")
visible := m.visibleModels()
maxShow := 12
if len(visible) == 0 {
ft := strings.TrimSpace(m.filterInput.Value())
if ft != "" {
body.WriteString(hint.Render(" no matches \u2014 enter to use as model name"))
} else {
body.WriteString(hint.Render(" type to filter models"))
}
} else {
start, end := scrollWindow(m.modelIdx, len(visible), maxShow)
if start > 0 {
body.WriteString(hint.Render(fmt.Sprintf(" ... %d more above", start)))
body.WriteString("\n")
}
for i := start; i < end; i++ {
if i == m.modelIdx {
body.WriteString(accent.Render("> " + visible[i]))
body.WriteString("\n")
} else {
body.WriteString(" " + visible[i] + "\n")
}
}
if end < len(visible) {
body.WriteString(hint.Render(fmt.Sprintf(" ... %d more below", len(visible)-end)))
body.WriteString("\n")
}
body.WriteString("\n")
body.WriteString(hint.Render(fmt.Sprintf(" %d/%d models", len(visible), len(m.models))))
}
if m.returnToSummary {
footer = hint.Render("type: filter up/down: move enter: select esc: cancel")
} else {
footer = hint.Render("type: filter up/down: move enter: select esc: back")
}
}
case phaseWorktree:
title = accent.Render("Worktree") + label.Render(" (optional)")
body.WriteString(m.worktreeInput.View())
footer = hint.Render("enter: update esc: cancel")
case phaseSummary:
ho := m.currentHarness()
vkState := "no"
if strings.TrimSpace(m.vkInput.Value()) != "" {
vkState = "yes"
}
baseURL := strings.TrimSpace(m.baseInput.Value())
model := m.currentModel()
harnessStr := ho.Label
if ho.Version != "" {
harnessStr += " (" + ho.Version + ")"
}
title = accent.Render("Ready to launch")
body.WriteString(label.Render(" Base URL ") + " " + baseURL + "\n")
body.WriteString(label.Render(" Harness ") + " " + harnessStr + "\n")
if ho.SupportsModelOverride {
body.WriteString(label.Render(" Model ") + " " + accent.Render(model) + "\n")
} else {
body.WriteString(label.Render(" Model ") + " " + hint.Render("managed by "+ho.Label) + "\n")
}
if ho.ID == "claude" && strings.Contains(strings.ToLower(model), "gemini") {
warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("208"))
body.WriteString(label.Render(" ") + " " + warnStyle.Render("⚠ Gemini function calling is not compatible with") + "\n")
body.WriteString(label.Render(" ") + " " + warnStyle.Render(" Claude Code and may not work as intended") + "\n")
}
body.WriteString(label.Render(" Virtual Key ") + " " + vkState + "\n")
if ho.SupportsWorktree {
wtState := "no"
if wt := strings.TrimSpace(m.worktreeInput.Value()); wt != "" {
wtState = wt
}
body.WriteString(label.Render(" Worktree ") + " " + wtState + "\n")
}
var fb strings.Builder
fb.WriteString(accent.Render("enter") + hint.Render(" launch "))
fb.WriteString(accent.Render("u") + hint.Render(" url "))
fb.WriteString(accent.Render("v") + hint.Render(" virtual key "))
if ho.SupportsWorktree {
fb.WriteString(accent.Render("w") + hint.Render(" worktree "))
}
fb.WriteString(accent.Render("h") + hint.Render(" harness "))
if ho.SupportsModelOverride {
fb.WriteString(accent.Render("m") + hint.Render(" model "))
}
fb.WriteString(accent.Render("d") + hint.Render(" dashboard "))
fb.WriteString(accent.Render("r") + hint.Render(" docs "))
fb.WriteString(accent.Render("i") + hint.Render(" report issue "))
fb.WriteString(accent.Render("s") + hint.Render(" star "))
fb.WriteString(accent.Render("q") + hint.Render(" quit"))
footer = fb.String()
}
// Compose: vertically center logo+content, footer at bottom
bodyStr := body.String()
// Center body: per-line for input phases, block-aligned for harness/summary
var alignedBody string
switch m.phase {
case phaseBaseURL, phaseVirtualKey, phaseWorktree, phaseModel:
alignedBody = centerBlock(bodyStr, w)
default:
alignedBody = centerBlockLeft(bodyStr, w)
}
// Combine title (if any) + body into content
var content strings.Builder
if title != "" {
content.WriteString(centerLine(title, w))
content.WriteString("\n\n")
}
content.WriteString(alignedBody)
contentStr := content.String()
if m.plainLayout {
return renderPlainChooserView(title, bodyStr, footer)
}
logoLines := strings.Count(logoBlock, "\n") + 1
contentLines := strings.Count(contentStr, "\n") + 1
// Calculate how many lines the footer will occupy after wrapping
footerLines := 1
if lipgloss.Width(footer) > w {
footerLines = strings.Count(wrapFooter(footer, w), "\n") + 1
}
// Actual rendered lines between topPad and bottomPad:
// logoLines + 1 (meta) + 1 (blank gap line) + contentLines
bodyHeight := logoLines + 2 + contentLines
topPad := (h - bodyHeight - footerLines) / 2
if topPad < 0 {
topPad = 0
}
bottomPad := h - topPad - bodyHeight - footerLines
if bottomPad < 1 {
bottomPad = 1
}
centeredLogo := centerBlock(logoBlock, w)
centeredMeta := centerLine(meta, w)
var out strings.Builder
if topPad > 0 {
out.WriteString(strings.Repeat("\n", topPad))
}
out.WriteString(centeredLogo)
out.WriteString("\n")
out.WriteString(centeredMeta)
out.WriteString("\n\n")
out.WriteString(contentStr)
// N newlines between two text blocks produce N-1 visible blank lines,
// so emit bottomPad+1 to get exactly bottomPad blank lines.
out.WriteString(strings.Repeat("\n", bottomPad+1))
// Wrap footer into multiple centered lines if it exceeds terminal width
if lipgloss.Width(footer) > w {
out.WriteString(wrapFooter(footer, w))
} else {
out.WriteString(centerLine(footer, w))
}
// Append the tab bar so it is part of Bubble Tea's render and survives
// screen clears on resize.
if m.cfg.TabBarLine != nil {
out.WriteString("\n\n")
out.WriteString(m.cfg.TabBarLine())
}
return out.String()
}
func prefersPlainChooserLayout() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv("TERM_PROGRAM")), "Apple_Terminal")
}
func renderPlainChooserView(title, body, footer string) string {
var out strings.Builder
out.WriteString("BIFROST CLI\n\n")
if title != "" {
out.WriteString(title)
out.WriteString("\n\n")
}
trimmedBody := strings.TrimRight(body, "\n")
if trimmedBody != "" {
out.WriteString(trimmedBody)
out.WriteString("\n")
}
if footer != "" {
out.WriteString("\n")
out.WriteString(strings.TrimSpace(footer))
}
return out.String()
}
func (m *chooserModel) notify(message string, isError bool) {
message = strings.TrimSpace(message)
if message == "" {
return
}
if m.cfg.Notify != nil {
m.cfg.Notify(message, isError)
return
}
m.message = message
}
// centerBlock centers each line of a multi-line string within the given width.
func centerBlock(block string, width int) string {
lines := strings.Split(block, "\n")
for i, line := range lines {
lines[i] = centerLine(line, width)
}
return strings.Join(lines, "\n")
}
// centerBlockLeft centers a multi-line block as a whole: it finds the widest
// line, calculates padding to center that width, then applies the same padding
// to every line so the block stays left-aligned internally.
func centerBlockLeft(block string, width int) string {
lines := strings.Split(block, "\n")
maxW := 0
for _, line := range lines {
if vw := lipgloss.Width(line); vw > maxW {
maxW = vw
}
}
if maxW >= width {
return block
}
pad := strings.Repeat(" ", (width-maxW)/2)
for i, line := range lines {
lines[i] = pad + line
}
return strings.Join(lines, "\n")
}
// centerLine pads a single line with leading spaces to center it within width.
func centerLine(line string, width int) string {
visible := lipgloss.Width(line)
if visible >= width {
return line
}
pad := (width - visible) / 2
return strings.Repeat(" ", pad) + line
}
// currentHarness returns the currently selected harness option.
func (m chooserModel) currentHarness() HarnessOption {
if len(m.cfg.Harnesses) == 0 {
return HarnessOption{}
}
if m.harnessIdx < 0 {
return m.cfg.Harnesses[0]
}
if m.harnessIdx >= len(m.cfg.Harnesses) {
return m.cfg.Harnesses[len(m.cfg.Harnesses)-1]
}
return m.cfg.Harnesses[m.harnessIdx]
}
// currentModel returns the currently selected model name from the visible
// (possibly filtered) model list. Returns empty string if no model is selected.
func (m chooserModel) currentModel() string {
visible := m.visibleModels()
if len(visible) == 0 {
return ""
}
if m.modelIdx < 0 || m.modelIdx >= len(visible) {
return ""
}
return strings.TrimSpace(visible[m.modelIdx])
}
// visibleModels returns the model list to display. If a filter is active,
// returns only the matching subset; otherwise returns all models.
func (m chooserModel) visibleModels() []string {
if m.filtered != nil {
out := make([]string, 0, len(m.filtered))
for _, idx := range m.filtered {
if idx < 0 || idx >= len(m.models) {
continue
}
out = append(out, m.models[idx])
}
return out
}
return m.models
}
// scrollWindow calculates the visible range [start, end) for a scrollable list,
// keeping the cursor centered within the window when possible.
func scrollWindow(cursor, total, maxVisible int) (start, end int) {
if total <= maxVisible {
return 0, total
}
half := maxVisible / 2
start = cursor - half
if start < 0 {
start = 0
}
end = start + maxVisible
if end > total {
end = total
start = end - maxVisible
}
return start, end
}
// wrapFooter splits a footer string into multiple centered lines so it fits
// within the given width. It splits at double-space boundaries between items.
func wrapFooter(footer string, width int) string {
// Split on double-space which separates footer items
parts := strings.Split(footer, " ")
var lines []string
current := ""
for _, p := range parts {
candidate := current
if candidate != "" {
candidate += " "
}
candidate += p
if lipgloss.Width(candidate) > width && current != "" {
lines = append(lines, centerLine(strings.TrimSpace(current), width))
current = p
} else {
current = candidate
}
}
if strings.TrimSpace(current) != "" {
lines = append(lines, centerLine(strings.TrimSpace(current), width))
}
return strings.Join(lines, "\n")
}
// openBrowser opens the given URL in the user's default browser.
func openBrowser(url string) {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
cmd = exec.Command("xdg-open", url)
}
_ = cmd.Start()
}
// fetchModelsCmd returns a tea.Cmd that asynchronously fetches available models
// from the Bifrost API and sends the result as a modelsMsg.
func (m chooserModel) fetchModelsCmd() tea.Cmd {
baseURL := strings.TrimSpace(m.baseInput.Value())
vk := strings.TrimSpace(m.vkInput.Value())
fetch := m.cfg.FetchModels
return func() tea.Msg {
if fetch == nil {
return modelsMsg{err: fmt.Errorf("model fetcher is not configured")}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
models, err := fetch(ctx, baseURL, vk)
return modelsMsg{models: models, err: err}
}
}