1058 lines
28 KiB
Go
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}
|
|
}
|
|
}
|