first commit
This commit is contained in:
54
cli/internal/ui/logo/logo.go
Normal file
54
cli/internal/ui/logo/logo.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package logo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const compact = "BIFROST CLI"
|
||||
|
||||
// Render returns the ASCII logo for the given terminal width.
|
||||
func Render(width int) string {
|
||||
if width < 61 {
|
||||
return compact
|
||||
}
|
||||
|
||||
return strings.Join([]string{
|
||||
"╔═══════════════════════════════════════════════════════════╗",
|
||||
"║ ║",
|
||||
"║ ██████╗ ██╗███████╗██████╗ ██████╗ ███████╗████████╗ ║",
|
||||
"║ ██╔══██╗██║██╔════╝██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝ ║",
|
||||
"║ ██████╔╝██║█████╗ ██████╔╝██║ ██║███████╗ ██║ ║",
|
||||
"║ ██╔══██╗██║██╔══╝ ██╔══██╗██║ ██║╚════██║ ██║ ║",
|
||||
"║ ██████╔╝██║██║ ██║ ██║╚██████╔╝███████║ ██║ ║",
|
||||
"║ ╚═════╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ║",
|
||||
"║ ║",
|
||||
"║═══════════════════════════════════════════════════════════║",
|
||||
"║ CLI ║",
|
||||
"║═══════════════════════════════════════════════════════════║",
|
||||
"║ https://github.com/maximhq/bifrost ║",
|
||||
"╚═══════════════════════════════════════════════════════════╝",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// BootHeader builds the full boot header with the ASCII logo and version info.
|
||||
func BootHeader(width int, version, commit, source string, noColor bool) string {
|
||||
if width < 61 {
|
||||
meta := fmt.Sprintf("%s (%s)", version, commit)
|
||||
return fmt.Sprintf("\n\n%s\n%s", Render(width), meta)
|
||||
}
|
||||
|
||||
meta := fmt.Sprintf("%s (%s) config=%s", version, commit, source)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(Render(width))
|
||||
b.WriteString("\n")
|
||||
if noColor {
|
||||
b.WriteString(meta)
|
||||
} else {
|
||||
b.WriteString("\033[2;36m" + meta + "\033[0m")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
30
cli/internal/ui/logo/logo_test.go
Normal file
30
cli/internal/ui/logo/logo_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package logo
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderLarge(t *testing.T) {
|
||||
got := Render(120)
|
||||
if !strings.Contains(got, "██████╗") {
|
||||
t.Fatalf("expected large logo, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCompact(t *testing.T) {
|
||||
got := Render(20)
|
||||
if got != "BIFROST CLI" {
|
||||
t.Fatalf("expected compact logo, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootHeaderStartsWithLogo(t *testing.T) {
|
||||
header := BootHeader(120, "v1", "abc", "none", true)
|
||||
if !strings.Contains(header, Render(120)) {
|
||||
t.Fatalf("expected boot header to contain logo")
|
||||
}
|
||||
if !strings.Contains(header, "config=none") {
|
||||
t.Fatalf("expected boot header to contain config source")
|
||||
}
|
||||
}
|
||||
1057
cli/internal/ui/tui/chooser.go
Normal file
1057
cli/internal/ui/tui/chooser.go
Normal file
File diff suppressed because it is too large
Load Diff
72
cli/internal/ui/tui/chooser_test.go
Normal file
72
cli/internal/ui/tui/chooser_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestPrefersPlainChooserLayoutAppleTerminal(t *testing.T) {
|
||||
old := os.Getenv("TERM_PROGRAM")
|
||||
t.Cleanup(func() {
|
||||
if old == "" {
|
||||
os.Unsetenv("TERM_PROGRAM")
|
||||
return
|
||||
}
|
||||
os.Setenv("TERM_PROGRAM", old)
|
||||
})
|
||||
|
||||
os.Setenv("TERM_PROGRAM", "Apple_Terminal")
|
||||
if !prefersPlainChooserLayout() {
|
||||
t.Fatal("expected Apple Terminal to use the plain chooser layout")
|
||||
}
|
||||
|
||||
os.Setenv("TERM_PROGRAM", "iTerm.app")
|
||||
if prefersPlainChooserLayout() {
|
||||
t.Fatal("did not expect iTerm to use the plain chooser layout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPlainChooserView(t *testing.T) {
|
||||
out := renderPlainChooserView("Ready", "base url\nmodel", "enter launch")
|
||||
|
||||
for _, want := range []string{"BIFROST CLI", "Ready", "base url", "model", "enter launch"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("expected output to contain %q, got %q", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooserViewShowsUpdatePrompt(t *testing.T) {
|
||||
m := newChooserModel(ChooserConfig{
|
||||
Version: "v1.0.0",
|
||||
Commit: "abc123",
|
||||
ConfigSrc: "test",
|
||||
UpdateVersion: "v1.2.3",
|
||||
})
|
||||
|
||||
view := m.View()
|
||||
|
||||
for _, want := range []string{"Update available:", "bifrost v1.2.3", "press y to update now"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("expected chooser view to contain %q, got %q", want, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooserUpdateShortcutRequestsUpdate(t *testing.T) {
|
||||
m := newChooserModel(ChooserConfig{
|
||||
UpdateVersion: "v1.2.3",
|
||||
})
|
||||
// Move to a non-text-entry phase so 'y' isn't consumed by the input field.
|
||||
m.phase = phaseSummary
|
||||
|
||||
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}})
|
||||
got := next.(chooserModel)
|
||||
|
||||
if !got.updateRequested {
|
||||
t.Fatal("expected y to request update when update is available")
|
||||
}
|
||||
}
|
||||
134
cli/internal/ui/tui/confirm.go
Normal file
134
cli/internal/ui/tui/confirm.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// confirmModel holds information about current model selection
|
||||
type confirmModel struct {
|
||||
header string
|
||||
prompt string
|
||||
command string
|
||||
idx int
|
||||
quit bool
|
||||
yes bool
|
||||
clearFirst bool
|
||||
}
|
||||
|
||||
// runConfirm runs a confirm dialog with the given model and returns the user's
|
||||
// choice.
|
||||
func runConfirm(m confirmModel) (bool, error) {
|
||||
p := tea.NewProgram(
|
||||
m,
|
||||
tea.WithInput(os.Stdin),
|
||||
tea.WithOutput(os.Stdout),
|
||||
tea.WithInputTTY(),
|
||||
)
|
||||
out, err := p.Run()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
fm, ok := out.(confirmModel)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("unexpected model type from tui")
|
||||
}
|
||||
if fm.quit {
|
||||
return false, nil
|
||||
}
|
||||
return fm.yes, nil
|
||||
}
|
||||
|
||||
// RunConfirmInstall displays a yes/no confirmation dialog asking the user
|
||||
// whether to install a missing harness. Returns true if the user confirms.
|
||||
func RunConfirmInstall(header, harnessLabel, command string) (bool, error) {
|
||||
return runConfirm(confirmModel{
|
||||
header: header,
|
||||
prompt: fmt.Sprintf("%s is not installed. Install now?", harnessLabel),
|
||||
command: command,
|
||||
})
|
||||
}
|
||||
|
||||
// RunConfirmSettings displays a yes/no confirmation dialog asking the user
|
||||
// whether to update the harness's native settings file. Returns true if the
|
||||
// user confirms.
|
||||
func RunConfirmSettings(harnessLabel, settingsPath string) (bool, error) {
|
||||
return runConfirm(confirmModel{
|
||||
prompt: fmt.Sprintf("Update %s settings? (%s)", harnessLabel, settingsPath),
|
||||
clearFirst: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Init implements tea.Model.
|
||||
func (m confirmModel) Init() tea.Cmd {
|
||||
if m.clearFirst {
|
||||
return tea.ClearScreen
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update implements tea.Model. Handles y/n, arrow keys, and enter for confirmation.
|
||||
func (m confirmModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
s := msg.String()
|
||||
switch s {
|
||||
case "ctrl+c", "q", "esc":
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
case "left", "h", "right", "l", "tab":
|
||||
if m.idx == 0 {
|
||||
m.idx = 1
|
||||
} else {
|
||||
m.idx = 0
|
||||
}
|
||||
return m, nil
|
||||
case "y":
|
||||
m.yes = true
|
||||
return m, tea.Quit
|
||||
case "n":
|
||||
m.yes = false
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
m.yes = m.idx == 0
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View implements tea.Model. Renders the confirmation prompt with Yes/No buttons.
|
||||
func (m confirmModel) View() string {
|
||||
selected := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42"))
|
||||
normal := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
|
||||
|
||||
yes := normal.Render("[ Yes ]")
|
||||
no := normal.Render("[ No ]")
|
||||
if m.idx == 0 {
|
||||
yes = selected.Render("[ Yes ]")
|
||||
} else {
|
||||
no = selected.Render("[ No ]")
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if m.header != "" {
|
||||
b.WriteString(m.header)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(m.prompt)
|
||||
b.WriteString("\n")
|
||||
if m.command != "" {
|
||||
b.WriteString("command: ")
|
||||
b.WriteString(m.command)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(yes + " " + no)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("enter: confirm, y/n quick choice, q: cancel")
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user