feat: remove TUI from README and codebase

This commit is contained in:
2026-08-18 16:24:28 +01:00
parent dc3bdfb2b1
commit ef0d6da347
13 changed files with 20 additions and 1224 deletions
+6 -16
View File
@@ -1,29 +1,19 @@
PREFIX ?= $(HOME)/.local
PROFILE_DIR = $(HOME)/.config/nono/profiles
BIN_DIR = $(PREFIX)/bin
TUI_DIR = tui
.PHONY: install uninstall validate test tui tui-build tui-install
.PHONY: install uninstall validate test
install:
@./scripts/install.sh
uninstall:
rm -f $(PROFILE_DIR)/opencode-tinfoil.json
rm -f $(BIN_DIR)/opencode
@echo "Uninstalled."
validate:
nono profile validate opencode-tinfoil
test:
$(BIN_DIR)/opencode --version
tui:
cd $(TUI_DIR) && go run .
tui-build:
cd $(TUI_DIR) && go build -buildvcs=false -o $(BIN_DIR)/nono-tui .
tui-install: tui-build
@echo "Installed nono-tui to $(BIN_DIR)/nono-tui"
+14 -110
View File
@@ -79,126 +79,35 @@ alias opencode="$HOME/.local/bin/opencode"
```
## Usage
From any project directory:
```bash
opencode
```
Or use the profile directly with any opencode binary:
```bash
nono run --profile opencode-tinfoil --allow-cwd -- opencode
```
You can also point it at a specific binary:
```bash
# Homebrew (macOS)
nono run --profile opencode-tinfoil --allow-cwd -- /opt/homebrew/bin/opencode
# Homebrew (Linux)
nono run --profile opencode-tinfoil --allow-cwd -- /home/linuxbrew/.linuxbrew/bin/opencode
# npm global
nono run --profile opencode-tinfoil --allow-cwd -- $(npm root -g)/opencode/bin/opencode
```
## TUI
A terminal UI for managing the sandbox profile and checking environment status.
### Run from the repo
```bash
make tui
```
### Install for use from anywhere
Build and install the binary to `~/.local/bin/nono-tui`:
```bash
make tui-install
```
Then run from any directory:
```bash
nono-tui
```
Make sure `~/.local/bin` is on your `PATH`. If it isn't, add this to your shell config file (e.g., `~/.bashrc` or `~/.zshrc`):
```bash
export PATH="$HOME/.local/bin:$PATH"
```
The TUI reads and edits the installed profile at
`~/.config/nono/profiles/opencode-tinfoil.json`, so it works regardless of
your current directory.
### What it does
The TUI has two tabs:
**Dashboard** — Shows live status of your sandbox setup at a glance:
```
✓ nono installed /home/linuxbrew/.linuxbrew/bin/nono nono 0.x.x
✓ profile installed ~/.config/nono/profiles/opencode-tinfoil.json
✓ wrapper installed ~/.local/bin/opencode
✗ wrapper first on PATH resolves to /opt/homebrew/bin/opencode
✓ opencode binary /opt/homebrew/bin/opencode
✓ tinfoil credentials ~/.tinfoil
> Launch opencode in sandbox
Validate profile
Refresh checks
```
Use `j/k` to navigate the action list, `l` to launch opencode in the sandbox,
`v` to validate the profile, and `r` to re-run all checks.
**Editor** — Edit the installed profile
(`~/.config/nono/profiles/opencode-tinfoil.json`) with four sub-tabs:
| Sub-tab | Key | What you can edit |
|---------|-----|-------------------|
| Commands | `1` | Allowed binaries (git, node, npm, ...) and blocked binaries (sudo, su, mkfs, ...) |
| Filesystem | `2` | Allowed, read-only, write, and denied paths |
| Network | `3` | Allowed domains (`*.tinfoil.sh`, `api.openai.com`, ...) and network block toggle |
| Environment | `4` | Set environment variables and denied env vars |
Changes are applied to the live installed profile — they take effect on the
next `opencode` launch. Press `s` to save (which also runs `nono profile
validate`). Saved files are written as clean JSON (comments are stripped).
### Keybindings
| Key | Action |
|-----|--------|
| `Tab` | Switch between Dashboard and Editor |
| `1`-`4` | Switch editor sub-tab |
| `j/k`, `↑/↓` | Move cursor |
| `←/→` | Switch between lists within a sub-tab |
| `a` | Add entry |
| `x` | Remove selected entry |
| `enter` | Edit selected entry inline |
| `b` | Toggle network block (Network tab only) |
| `s` | Save profile + validate |
| `l` | Launch opencode in sandbox (Dashboard only) |
| `v` | Validate profile (Dashboard only) |
| `r` | Refresh checks (Dashboard only) |
| `q` | Quit |
| `Q` | Force quit (discard unsaved changes) |
Unsaved changes are marked with `*` in the title. Pressing `q` with unsaved
changes warns you; press `Q` to force quit.
## Tinfoil integration
The profile allows network access to:
- `*.tinfoil.sh`
@@ -224,19 +133,14 @@ and grants read/write access to `~/.tinfoil` for Tinfoil credentials.
| Denied | `AWS_*`, `GOOGLE_*`, `AZURE_*`, `KUBECONFIG`, `SSH_AUTH_SOCK` |
## Uninstall
```bash
make uninstall
```
To also remove the TUI binary:
```bash
rm -f ~/.local/bin/nono-tui
```
## License
GPL-3.0
-105
View File
@@ -1,105 +0,0 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
type CheckStatus int
const (
StatusUnknown CheckStatus = iota
StatusOK
StatusFail
)
type CheckResult struct {
Label string
Status CheckStatus
Detail string
}
func RunChecks() []CheckResult {
var results []CheckResult
if path, err := exec.LookPath("nono"); err == nil {
version := getNonoVersion()
detail := path
if version != "" {
detail += " " + version
}
results = append(results, CheckResult{"nono installed", StatusOK, detail})
} else {
results = append(results, CheckResult{"nono installed", StatusFail, "not found in PATH"})
}
profilePath, _ := DefaultProfilePath()
if profilePath != "" {
if info, err := os.Stat(profilePath); err == nil {
results = append(results, CheckResult{"profile installed", StatusOK,
profilePath + " (" + info.ModTime().Format("2006-01-02") + ")"})
} else {
results = append(results, CheckResult{"profile installed", StatusFail, "not found"})
}
}
home, _ := os.UserHomeDir()
wrapperPath := filepath.Join(home, ".local", "bin", "opencode")
if _, err := os.Stat(wrapperPath); err == nil {
results = append(results, CheckResult{"wrapper installed", StatusOK, wrapperPath})
} else {
results = append(results, CheckResult{"wrapper installed", StatusFail, "not found"})
}
if path, err := exec.LookPath("opencode"); err == nil {
if path == wrapperPath {
results = append(results, CheckResult{"wrapper first on PATH", StatusOK, path})
} else {
results = append(results, CheckResult{"wrapper first on PATH", StatusFail, "resolves to " + path})
}
} else {
results = append(results, CheckResult{"wrapper first on PATH", StatusFail, "opencode not in PATH"})
}
opencodePath := findRealOpencode(wrapperPath)
if opencodePath != "" {
results = append(results, CheckResult{"opencode binary", StatusOK, opencodePath})
} else {
results = append(results, CheckResult{"opencode binary", StatusFail, "real opencode not found"})
}
tinfoilDir := filepath.Join(home, ".tinfoil")
if info, err := os.Stat(tinfoilDir); err == nil && info.IsDir() {
results = append(results, CheckResult{"tinfoil credentials", StatusOK, tinfoilDir})
} else {
results = append(results, CheckResult{"tinfoil credentials", StatusFail, "no ~/.tinfoil directory"})
}
return results
}
func getNonoVersion() string {
cmd := exec.Command("nono", "--version")
out, err := cmd.Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}
func findRealOpencode(wrapperPath string) string {
path := os.Getenv("PATH")
for _, dir := range filepath.SplitList(path) {
for _, name := range []string{"opencode", "opencode.exe"} {
candidate := filepath.Join(dir, name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
if candidate != wrapperPath {
return candidate
}
}
}
}
return ""
}
-148
View File
@@ -1,148 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
type DashboardModel struct {
checks []CheckResult
cursor int
maxCursor int
output string
width int
}
func NewDashboardModel(checks []CheckResult) DashboardModel {
return DashboardModel{
checks: checks,
maxCursor: 2,
}
}
type checksDoneMsg struct {
results []CheckResult
}
type launchResultMsg struct {
err error
}
type validateResultMsg struct {
output string
err error
}
func runChecksCmd() tea.Cmd {
return func() tea.Msg {
return checksDoneMsg{results: RunChecks()}
}
}
func launchOpencodeCmd() tea.Cmd {
home, _ := os.UserHomeDir()
wrapper := filepath.Join(home, ".local", "bin", "opencode")
cmd := exec.Command(wrapper)
return tea.ExecProcess(cmd, func(err error) tea.Msg {
return launchResultMsg{err: err}
})
}
func validateProfileCmd() tea.Cmd {
return func() tea.Msg {
cmd := exec.Command("nono", "profile", "validate", "opencode-tinfoil")
out, err := cmd.CombinedOutput()
return validateResultMsg{output: strings.TrimSpace(string(out)), err: err}
}
}
func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) {
switch msg := msg.(type) {
case checksDoneMsg:
m.checks = msg.results
return m, nil
case launchResultMsg:
if msg.err != nil {
m.output = fmt.Sprintf("Launch failed: %v", msg.err)
} else {
m.output = "opencode exited normally"
}
return m, nil
case validateResultMsg:
if msg.err != nil {
m.output = "Validation: " + msg.output
} else {
m.output = "Validation OK: " + msg.output
}
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < m.maxCursor {
m.cursor++
}
case "l":
if m.cursor == 0 {
return m, launchOpencodeCmd()
}
case "v":
if m.cursor == 1 {
return m, validateProfileCmd()
}
case "r":
m.output = "Refreshing..."
return m, runChecksCmd()
}
}
return m, nil
}
func (m DashboardModel) View() string {
var b strings.Builder
b.WriteString("Dashboard\n\n")
for _, check := range m.checks {
var icon string
style := checkOKStyle
if check.Status == StatusOK {
icon = "✓"
} else {
icon = "✗"
style = checkFailStyle
}
b.WriteString(fmt.Sprintf(" %s %s %s\n",
style.Render(icon),
labelStyle.Render(check.Label),
detailStyle.Render(check.Detail),
))
}
b.WriteString("\n")
actions := []string{"Launch opencode in sandbox", "Validate profile", "Refresh checks"}
for i, action := range actions {
cursor := " "
if i == m.cursor {
cursor = cursorStyle.Render("> ")
}
b.WriteString(fmt.Sprintf("%s%s\n", cursor, action))
}
if m.output != "" {
b.WriteString("\n" + detailStyle.Render(m.output))
}
b.WriteString("\n\n" + helpStyle.Render(" l:launch v:validate r:refresh Tab:editor"))
return b.String()
}
-224
View File
@@ -1,224 +0,0 @@
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
}
-28
View File
@@ -1,28 +0,0 @@
module nono-opencode-tinfoil/tui
go 1.18
require (
github.com/charmbracelet/bubbles v0.16.1
github.com/charmbracelet/bubbletea v0.24.2
github.com/charmbracelet/lipgloss v0.7.1
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.6.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
-41
View File
@@ -1,41 +0,0 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E=
github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs=
github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
-143
View File
@@ -1,143 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type listMode int
const (
listBrowse listMode = iota
listAdding
listEditing
)
type EditableList struct {
title string
items []string
cursor int
mode listMode
input textinput.Model
}
func NewEditableList(title string, items []string) EditableList {
ti := textinput.New()
ti.Placeholder = "enter value..."
ti.CharLimit = 200
return EditableList{
title: title,
items: items,
mode: listBrowse,
input: ti,
}
}
func (l EditableList) Items() []string {
return l.items
}
func (l EditableList) IsEditing() bool {
return l.mode != listBrowse
}
func (l EditableList) Update(msg tea.Msg) (EditableList, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch l.mode {
case listBrowse:
switch msg.String() {
case "up", "k":
if l.cursor > 0 {
l.cursor--
}
case "down", "j":
if l.cursor < len(l.items)-1 {
l.cursor++
}
case "a":
l.mode = listAdding
l.input.SetValue("")
l.input.Focus()
return l, textinput.Blink
case "x", "delete":
if len(l.items) > 0 && l.cursor < len(l.items) {
l.items = append(l.items[:l.cursor], l.items[l.cursor+1:]...)
if l.cursor > len(l.items)-1 {
l.cursor = len(l.items) - 1
}
if l.cursor < 0 {
l.cursor = 0
}
}
case "enter":
if len(l.items) > 0 {
l.mode = listEditing
l.input.SetValue(l.items[l.cursor])
l.input.Focus()
return l, textinput.Blink
}
}
case listAdding, listEditing:
switch msg.String() {
case "enter":
val := strings.TrimSpace(l.input.Value())
if val != "" {
if l.mode == listAdding {
l.items = append(l.items, val)
l.cursor = len(l.items) - 1
} else {
l.items[l.cursor] = val
}
}
l.mode = listBrowse
l.input.Blur()
case "esc":
l.mode = listBrowse
l.input.Blur()
default:
var cmd tea.Cmd
l.input, cmd = l.input.Update(msg)
return l, cmd
}
}
}
return l, nil
}
func (l EditableList) View() string {
var b strings.Builder
switch l.mode {
case listAdding:
b.WriteString(fmt.Sprintf("%s (adding)\n", l.title))
b.WriteString(fmt.Sprintf("> %s\n", l.input.View()))
b.WriteString(helpStyle.Render(" enter to confirm · esc to cancel"))
case listEditing:
b.WriteString(fmt.Sprintf("%s (editing)\n", l.title))
b.WriteString(fmt.Sprintf("> %s\n", l.input.View()))
b.WriteString(helpStyle.Render(" enter to confirm · esc to cancel"))
default:
b.WriteString(l.title + "\n")
if len(l.items) == 0 {
b.WriteString(helpStyle.Render(" (empty)"))
}
for i, item := range l.items {
cursor := " "
if i == l.cursor {
cursor = cursorStyle.Render("> ")
}
b.WriteString(fmt.Sprintf("%s%s\n", cursor, item))
}
}
content := b.String()
style := boxStyle
if l.mode != listBrowse {
style = activeBoxStyle
}
return style.Render(strings.TrimRight(content, "\n"))
}
-32
View File
@@ -1,32 +0,0 @@
package main
import (
"fmt"
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
func saveAndValidateCmd(path string, p *Profile) tea.Cmd {
return func() tea.Msg {
if err := SaveProfile(path, p); err != nil {
return saveResultMsg{err: err}
}
cmd := exec.Command("nono", "profile", "validate", "opencode-tinfoil")
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
return saveResultMsg{output: output, err: fmt.Errorf("validation failed")}
}
return saveResultMsg{output: output}
}
}
func main() {
m, _ := NewModel()
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
fmt.Printf("Error: %v\n", err)
}
}
-172
View File
@@ -1,172 +0,0 @@
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()
}
-128
View File
@@ -1,128 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
)
type Profile struct {
Meta ProfileMeta `json:"meta"`
Groups ProfileGroups `json:"groups"`
Commands ProfileCommands `json:"commands"`
Workdir ProfileWorkdir `json:"workdir"`
FS ProfileFS `json:"filesystem"`
Network ProfileNetwork `json:"network"`
Env ProfileEnv `json:"environment"`
}
type ProfileMeta struct {
Name string `json:"name"`
Description string `json:"description"`
}
type ProfileGroups struct {
Include []string `json:"include"`
}
type ProfileCommands struct {
Allow []string `json:"allow"`
Deny []string `json:"deny"`
}
type ProfileWorkdir struct {
Access string `json:"access"`
}
type ProfileFS struct {
Allow []string `json:"allow"`
Read []string `json:"read"`
Write []string `json:"write"`
Deny []string `json:"deny"`
}
type ProfileNetwork struct {
Block bool `json:"block"`
AllowDomain []string `json:"allow_domain"`
}
type ProfileEnv struct {
SetVars map[string]string `json:"set_vars"`
DenyVars []string `json:"deny_vars"`
}
func DefaultProfilePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "nono", "profiles", "opencode-tinfoil.json"), nil
}
func LoadProfile(path string) (*Profile, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
clean := stripJSONCComments(raw)
var p Profile
if err := json.Unmarshal(clean, &p); err != nil {
return nil, err
}
return &p, nil
}
func SaveProfile(path string, p *Profile) error {
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
func stripJSONCComments(data []byte) []byte {
var result []byte
inString := false
escaped := false
i := 0
for i < len(data) {
c := data[i]
if inString {
result = append(result, c)
if escaped {
escaped = false
} else if c == '\\' {
escaped = true
} else if c == '"' {
inString = false
}
i++
continue
}
if c == '"' {
inString = true
result = append(result, c)
i++
continue
}
if c == '/' && i+1 < len(data) {
if data[i+1] == '/' {
for i < len(data) && data[i] != '\n' {
i++
}
continue
}
if data[i+1] == '*' {
i += 2
for i+1 < len(data) && !(data[i] == '*' && data[i+1] == '/') {
i++
}
i += 2
continue
}
}
result = append(result, c)
i++
}
return result
}
-77
View File
@@ -1,77 +0,0 @@
package main
import "github.com/charmbracelet/lipgloss"
var (
green = lipgloss.Color("#04B575")
red = lipgloss.Color("#FF4646")
yellow = lipgloss.Color("#FFA500")
blue = lipgloss.Color("#7D56F4")
gray = lipgloss.Color("#666666")
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(blue).
Padding(0, 2)
tabStyle = lipgloss.NewStyle().
Foreground(gray).
Padding(0, 2)
activeTabStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(blue).
Padding(0, 2)
checkOKStyle = lipgloss.NewStyle().
Foreground(green).
Bold(true)
checkFailStyle = lipgloss.NewStyle().
Foreground(red).
Bold(true)
labelStyle = lipgloss.NewStyle().
Width(26).
Foreground(lipgloss.Color("#AAAAAA"))
detailStyle = lipgloss.NewStyle().
Foreground(gray)
boxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(gray).
Padding(0, 1)
activeBoxStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(blue).
Padding(0, 1)
cursorStyle = lipgloss.NewStyle().
Foreground(yellow).
Bold(true)
dirtyStyle = lipgloss.NewStyle().
Foreground(yellow).
Bold(true)
helpStyle = lipgloss.NewStyle().
Foreground(gray).
Italic(true)
statusBarStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#333333")).
Foreground(lipgloss.Color("#FFFFFF")).
Padding(0, 1)
errorStyle = lipgloss.NewStyle().
Foreground(red).
Bold(true)
successStyle = lipgloss.NewStyle().
Foreground(green).
Bold(true)
)
BIN
View File
Binary file not shown.