- 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
225 lines
4.8 KiB
Go
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
|
|
}
|