Files
nono-opencode-tinfoil/tui/model.go
T
kawaiipunk a5f97a3a1b 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
2026-08-12 19:37:54 +01:00

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()
}