- Add Go/Bubble Tea TUI app under tui/ with dashboard (status checks, launch, validate) and profile editor (commands, filesystem, network, environment sub-tabs) - Add Makefile targets: tui, tui-build, tui-install - Add CI step to build TUI - Relicense MIT -> GPL-3.0 - Rewrite wrapper to resolve real opencode binary from PATH (supports brew, npm, volta) - Add PATH ordering detection to install script - Expand README with how-it-works diagram, TUI docs, keybinding table
173 lines
3.1 KiB
Go
173 lines
3.1 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
type mainTab int
|
|
|
|
const (
|
|
tabDashboard mainTab = iota
|
|
tabEditor
|
|
)
|
|
|
|
var mainTabNames = []string{"Dashboard", "Editor"}
|
|
|
|
type saveResultMsg struct {
|
|
output string
|
|
err error
|
|
}
|
|
|
|
type Model struct {
|
|
tab mainTab
|
|
dashboard DashboardModel
|
|
editor EditorModel
|
|
profile *Profile
|
|
profilePath string
|
|
dirty bool
|
|
statusMsg string
|
|
errMsg string
|
|
width int
|
|
height int
|
|
quitting bool
|
|
}
|
|
|
|
func NewModel() (Model, tea.Cmd) {
|
|
profilePath, _ := DefaultProfilePath()
|
|
p, err := LoadProfile(profilePath)
|
|
if err != nil {
|
|
p = &Profile{}
|
|
}
|
|
|
|
checks := RunChecks()
|
|
|
|
m := Model{
|
|
tab: tabDashboard,
|
|
dashboard: NewDashboardModel(checks),
|
|
editor: NewEditorModel(p),
|
|
profile: p,
|
|
profilePath: profilePath,
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.dashboard.width = msg.Width
|
|
m.editor.width = msg.Width
|
|
return m, nil
|
|
|
|
case saveResultMsg:
|
|
if msg.err != nil {
|
|
m.errMsg = fmt.Sprintf("Save failed: %v", msg.err)
|
|
} else {
|
|
m.dirty = false
|
|
m.statusMsg = "Saved & validated: " + msg.output
|
|
m.errMsg = ""
|
|
}
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
if m.dirty {
|
|
m.statusMsg = "Unsaved changes! Press Q to force quit, or s to save."
|
|
return m, nil
|
|
}
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
case "Q":
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
case "tab":
|
|
if m.tab == tabDashboard {
|
|
m.tab = tabEditor
|
|
} else {
|
|
m.tab = tabDashboard
|
|
}
|
|
m.statusMsg = ""
|
|
m.errMsg = ""
|
|
return m, nil
|
|
case "s":
|
|
if m.tab == tabEditor {
|
|
m.editor.SyncToProfile(m.profile)
|
|
cmd := saveAndValidateCmd(m.profilePath, m.profile)
|
|
m.statusMsg = "Saving..."
|
|
return m, cmd
|
|
}
|
|
}
|
|
}
|
|
|
|
switch m.tab {
|
|
case tabDashboard:
|
|
var cmd tea.Cmd
|
|
m.dashboard, cmd = m.dashboard.Update(msg)
|
|
return m, cmd
|
|
case tabEditor:
|
|
var cmd tea.Cmd
|
|
m.editor, cmd = m.editor.Update(msg)
|
|
if !m.dirty {
|
|
m.dirty = true
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
if m.quitting {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
title := "nono-tui"
|
|
if m.dirty {
|
|
title += " *"
|
|
}
|
|
b.WriteString(titleStyle.Render(" " + title + " "))
|
|
|
|
tabs := []string{}
|
|
for i, name := range mainTabNames {
|
|
if mainTab(i) == m.tab {
|
|
tabs = append(tabs, activeTabStyle.Render(name))
|
|
} else {
|
|
tabs = append(tabs, tabStyle.Render(name))
|
|
}
|
|
}
|
|
b.WriteString(" " + strings.Join(tabs, " "))
|
|
|
|
b.WriteString("\n\n")
|
|
|
|
switch m.tab {
|
|
case tabDashboard:
|
|
b.WriteString(m.dashboard.View())
|
|
case tabEditor:
|
|
b.WriteString(m.editor.View())
|
|
}
|
|
|
|
b.WriteString("\n\n")
|
|
|
|
if m.errMsg != "" {
|
|
b.WriteString(errorStyle.Render(" " + m.errMsg))
|
|
} else if m.statusMsg != "" {
|
|
b.WriteString(successStyle.Render(" " + m.statusMsg))
|
|
} else {
|
|
b.WriteString(helpStyle.Render(" q:quit Tab:switch tabs"))
|
|
}
|
|
|
|
return b.String()
|
|
}
|