Files
nono-opencode-tinfoil/tui/editor.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

225 lines
4.8 KiB
Go

package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type editorTab int
const (
tabCommands editorTab = iota
tabFilesystem
tabNetwork
tabEnv
)
var editorTabNames = []string{"Commands", "Filesystem", "Network", "Environment"}
type EditorModel struct {
subTab editorTab
cmdLists [2]EditableList
fsLists [4]EditableList
netList EditableList
envLists [2]EditableList
netBlock bool
listIdx int
width int
}
func NewEditorModel(p *Profile) EditorModel {
m := EditorModel{subTab: tabCommands}
if p != nil {
m.cmdLists[0] = NewEditableList("Allow", p.Commands.Allow)
m.cmdLists[1] = NewEditableList("Deny", p.Commands.Deny)
m.fsLists[0] = NewEditableList("Allow", p.FS.Allow)
m.fsLists[1] = NewEditableList("Read", p.FS.Read)
m.fsLists[2] = NewEditableList("Write", p.FS.Write)
m.fsLists[3] = NewEditableList("Deny", p.FS.Deny)
m.netList = NewEditableList("Allow Domains", p.Network.AllowDomain)
m.envLists[0] = NewEditableList("Set Vars (key=value)", keyValueStrings(p.Env.SetVars))
m.envLists[1] = NewEditableList("Deny Vars", p.Env.DenyVars)
m.netBlock = p.Network.Block
}
return m
}
func (m EditorModel) listCount() int {
switch m.subTab {
case tabCommands:
return 2
case tabFilesystem:
return 4
case tabNetwork:
return 1
case tabEnv:
return 2
}
return 0
}
func (m EditorModel) currentList() EditableList {
switch m.subTab {
case tabCommands:
return m.cmdLists[m.listIdx]
case tabFilesystem:
return m.fsLists[m.listIdx]
case tabNetwork:
return m.netList
case tabEnv:
return m.envLists[m.listIdx]
}
return EditableList{}
}
func (m EditorModel) withList(idx int, l EditableList) EditorModel {
switch m.subTab {
case tabCommands:
m.cmdLists[idx] = l
case tabFilesystem:
m.fsLists[idx] = l
case tabNetwork:
m.netList = l
case tabEnv:
m.envLists[idx] = l
}
return m
}
func (m EditorModel) listTitles() []string {
switch m.subTab {
case tabCommands:
return []string{"Allow", "Deny"}
case tabFilesystem:
return []string{"Allow", "Read", "Write", "Deny"}
case tabNetwork:
return []string{"Allow Domains"}
case tabEnv:
return []string{"Set Vars", "Deny Vars"}
}
return nil
}
func (m EditorModel) Update(msg tea.Msg) (EditorModel, tea.Cmd) {
current := m.currentList()
if current.IsEditing() {
updated, cmd := current.Update(msg)
m = m.withList(m.listIdx, updated)
return m, cmd
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "1":
m.subTab = tabCommands
m.listIdx = 0
return m, nil
case "2":
m.subTab = tabFilesystem
m.listIdx = 0
return m, nil
case "3":
m.subTab = tabNetwork
m.listIdx = 0
return m, nil
case "4":
m.subTab = tabEnv
m.listIdx = 0
return m, nil
case "left":
if m.listIdx > 0 {
m.listIdx--
}
return m, nil
case "right":
if m.listIdx < m.listCount()-1 {
m.listIdx++
}
return m, nil
case "b":
if m.subTab == tabNetwork {
m.netBlock = !m.netBlock
return m, nil
}
default:
updated, cmd := current.Update(msg)
m = m.withList(m.listIdx, updated)
return m, cmd
}
}
return m, nil
}
func (m EditorModel) View() string {
var b strings.Builder
b.WriteString(" ")
for i, name := range editorTabNames {
num := fmt.Sprintf("%d", i+1)
if editorTab(i) == m.subTab {
b.WriteString(activeTabStyle.Render(num + " " + name))
} else {
b.WriteString(tabStyle.Render(num + " " + name))
}
b.WriteString(" ")
}
b.WriteString("\n\n")
current := m.currentList()
titles := m.listTitles()
if len(titles) > 1 {
list := current
list.title = fmt.Sprintf("%s (%d/%d)", titles[m.listIdx], m.listIdx+1, m.listCount())
b.WriteString(list.View())
} else {
b.WriteString(current.View())
}
if m.subTab == tabNetwork {
blockStr := "no"
if m.netBlock {
blockStr = "yes"
}
b.WriteString("\n\n Block: " + blockStr + " (b to toggle)")
}
b.WriteString("\n\n" + helpStyle.Render(" 1-4:sub-tab <-/->:switch list a:add x:remove enter:edit s:save Tab:dashboard"))
return b.String()
}
func (m EditorModel) SyncToProfile(p *Profile) {
p.Commands.Allow = m.cmdLists[0].Items()
p.Commands.Deny = m.cmdLists[1].Items()
p.FS.Allow = m.fsLists[0].Items()
p.FS.Read = m.fsLists[1].Items()
p.FS.Write = m.fsLists[2].Items()
p.FS.Deny = m.fsLists[3].Items()
p.Network.AllowDomain = m.netList.Items()
p.Network.Block = m.netBlock
p.Env.SetVars = parseKeyValueStrings(m.envLists[0].Items())
p.Env.DenyVars = m.envLists[1].Items()
}
func keyValueStrings(m map[string]string) []string {
var result []string
for k, v := range m {
result = append(result, k+"="+v)
}
return result
}
func parseKeyValueStrings(items []string) map[string]string {
result := make(map[string]string)
for _, item := range items {
parts := strings.SplitN(item, "=", 2)
if len(parts) == 2 {
result[parts[0]] = parts[1]
}
}
return result
}