Add TUI for managing sandbox profile, relicense to GPLv3
- 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
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user