# PROJECT: voxy_import > Machine-readable project documentation for LLM agents. > Read this before making any changes. Follow all conventions exactly. ## 1. OVERVIEW **A Minecraft TUI management tool built with bubbletea (Elm Architecture).** Master menu lists feature modules. User selects a module with Enter. Esc returns from module to menu. Ctrl+C quits globally. ``` Module: voxy_import Go: 1.25.5 Binary: voxy_import.exe Build: go build -o voxy_import.exe . Dependencies (from go.mod): github.com/charmbracelet/bubbles v1.0.0 // TUI components (list, textinput, spinner, progress) github.com/charmbracelet/bubbletea v1.3.10 // Elm Architecture for TUIs github.com/charmbracelet/lipgloss v1.1.0 // Styling ``` ## 2. FILE STRUCTURE ``` voxy_import/ ├── AGENTS.md ← this file ├── go.mod ├── go.sum ├── main.go ← entry point: register modules, run app ├── app/ │ └── app.go ← framework: Module interface, registry, menu model, key dispatch └── modules/ └── voxyimport/ ├── voxyimport.go ← TUI module + VoxyService interface definition └── service.go ← DefaultVoxyService concrete implementation ``` **When adding a new module `foo`:** ``` voxy_import/ └── modules/ └── foo/ ├── foo.go ← TUI module + FooService interface definition └── service.go ← concrete service implementation (if needed) ``` Each module is self-contained under `modules//` — TUI views, state machine, service interface, and default implementation all live in one package. Deleting a module = removing its directory + one line in `main.go`. ## 3. ARCHITECTURE ### app package: framework The `app` package provides the unified TUI framework: `Module` interface, module registry, menu model, and key dispatch. It does NOT import any module or service packages. ``` app.Model { menu list.Model // built from entries active tea.Model // nil = showing menu; non-nil = active module entries []Entry // registered modules with factory functions } ``` ### Module Interface Defined in `app/app.go`: ```go type Module interface { tea.Model Title() string Description() string } ``` A module MUST implement: - `Init() tea.Cmd` - `Update(tea.Msg) (tea.Model, tea.Cmd)` - `View() string` - `Title() string` — shown in menu list - `Description() string` — shown in menu list subtitle Module packages implement this interface implicitly (Go structural typing). They do NOT import app or reference the Module interface directly. ### Service Interface (per-module) Each module defines its own service interface in the module package. The default implementation lives alongside it (e.g. `service.go` in the same package). This keeps each module self-contained — one directory = one module = one package. ``` modules/voxyimport/ voxyimport.go ← VoxyService interface + TUI Model service.go ← DefaultVoxyService struct implements VoxyService ``` Deleting a module is a single operation: remove the directory, remove one line from `main.go`. ### Key Bindings | Key | Context | Action | |-----|---------|--------| | Ctrl+C | anywhere | `tea.Quit` — exit program | | Esc | inside module | return to menu (`m.active = nil`) | | Esc | on menu | `tea.Quit` — exit program | | Enter | on menu | activate selected module, call `Init()` | | Enter | inside module | module-specific (e.g. confirm input) | | ↑↓ / j k | list views | navigate | | q | on menu or module done/error states | handled per-module | Key dispatch logic in `app/app.go`: ```go case "ctrl+c": return m, tea.Quit case "esc": if m.active != nil { m.active = nil; return m, nil } return m, tea.Quit ``` **Modules do NOT handle Esc or Ctrl+C.** These are intercepted by app before delegate. Modules SHOULD handle Enter for confirmation. Modules MAY handle `q` for internal back/quit. ### Module Lifecycle ``` 1. User presses Enter on menu item 2. app matches title → calls create() → stores as m.active 3. app calls m.active.Init() → returns tea.Cmd (e.g. textinput.Blink, spinner.Tick) 4. All subsequent messages routed via updateModule() → m.active.Update(msg) 5. Result stored back: m.active = newMod 6. User presses Esc → m.active = nil (module discarded, no explicit cleanup) 7. User presses Ctrl+C → tea.Quit (main defer calls module.Cleanup() if exported) ``` ### New Module Registration In `main.go`, `entries` slice: ```go entries := []app.Entry{ { Title: "Module Name", Desc: "description", Create: func() tea.Model { return modname.New(svc) }, }, } ``` Then add the import: `"amt/modules/modname"` at top of main.go. ## 4. MODULE CODE CONVENTIONS ### 4.1 Package Layout Every module file follows this section order: ``` 1. package declaration + imports 2. module metadata: Title(), Description() 3. module-specific config/data (sources, constants) 4. state machine: type state int + const iota 5. sub-steps (if applicable): const iota 6. lipgloss styles: var block 7. custom message types: type ...Msg struct{} 8. shared-memory tracker types (if async with goroutines) 9. temporary file tracking (if module creates temp files) 10. list item types: type ...Item struct + FilterValue/Title/Description 11. Model struct (exported) 12. New() constructor function → returns *Model 13. Init() tea.Cmd 14. Update() → state dispatcher 15. Per-state update methods: updateInput, updateSearching, ... 16. View() → state dispatcher 17. Per-state view methods: viewInput, viewSearching, ... 18. Async commands: pure functions returning tea.Cmd ``` ### 4.2 Model Type ```go // ALWAYS exported as "Model" with pointer receiver methods // ALL fields unexported (lowercase) type Model struct { state state // ALWAYS: state field (custom type) // input widgets textInput textinput.Model // spinner (for waiting states) spinner spinner.Model // list (for selection states) list list.Model // progress bar (for download/processing) progress progress.Model // module-specific data fields errMsg string // ALWAYS: error message for stateError } ``` Constructor ALWAYS returns `*Model`: ```go func New() *Model { // create all components with defaults return &Model{state: stateInput, ...} } ``` ### 4.3 Receiver Types ALL methods on Model use **pointer receiver** `(m *Model)`. ```go func (m *Model) Init() tea.Cmd func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) func (m *Model) View() string func (m *Model) Title() string ``` ### 4.4 State Machine ```go type state int const ( stateInput state = iota // first state = initial stateProcessing stateDone stateError // always last (shared handler with stateDone) ) ``` State dispatcher in `Update()`: ```go func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // handle cross-cutting messages first: // tea.WindowSizeMsg → resize components // progress.FrameMsg → forward to progress bar switch m.state { case stateInput: return m.updateInput(msg) case stateProcessing: return m.updateProcessing(msg) case stateDone, stateError: return m.updateDone(msg) } return m, nil } ``` State dispatcher in `View()`: ```go func (m *Model) View() string { switch m.state { case stateInput: return m.viewInput() case stateDone: return m.viewDone() case stateError: return m.viewError() } return "" } ``` ### 4.5 Messages and Async Commands **Pattern: one-shot async operation** ```go // 1. Define message type type myDoneMsg struct { result string err error } // 2. Create command function (returns tea.Cmd) func myAsyncCmd(param string) tea.Cmd { return func() tea.Msg { // blocking work here (runs in goroutine via tea.Batch) result, err := doWork(param) if err != nil { return myDoneMsg{err: err} } return myDoneMsg{result: result} } } // 3. Handle in Update case myDoneMsg: if msg.err != nil { m.state = stateError m.errMsg = msg.err.Error() return m, nil } m.state = stateDone return m, nil ``` **Pattern: progress-tracked async with goroutine** Used when a long operation needs progress bar updates. See Section 6 for the full tracker pattern. ### 4.6 Components | Component | Import | Init | Update pattern | |-----------|--------|------|----------------| | `textinput` | `bubbles/textinput` | `ti := textinput.New(); ti.Focus()` | `m.textInput, cmd = m.textInput.Update(msg)` | | `spinner` | `bubbles/spinner` | `s := spinner.New(); s.Spinner = spinner.Dot` | Init returns `m.spinner.Tick`; per tick: `m.spinner, cmd = m.spinner.Update(msg)` | | `list` | `bubbles/list` | `l := list.New(items, list.NewDefaultDelegate(), 0, 0)` | `m.list, cmd = m.list.Update(msg)` | | `progress` | `bubbles/progress` | `p := progress.New(progress.WithDefaultGradient())` | `m.progress, cmd = m.progress.Update(msg)` (via FrameMsg) | **List items must implement:** ```go type item struct { title string; desc string } func (i item) FilterValue() string { return i.title } func (i item) Title() string { return i.title } func (i item) Description() string { return i.desc } ``` **List selection (Enter key):** ```go case "enter": if m.list.FilterState() == list.Filtering { break } // ignore during filter if sel, ok := m.list.SelectedItem().(myItem); ok { // use sel } ``` ### 4.7 Styles ```go var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) ) ``` ### 4.8 Chinese UI ALL user-facing strings are in Chinese (简体中文). Comments and identifiers are in English. Log messages (fmt.Sprintf for error) can be either. ### 4.9 Temp File Cleanup If a module creates temp files that outlive a single Update cycle, it MUST track them and export a `Cleanup()` function: ```go var ( pendingTempMu sync.Mutex pendingTemps = map[string]struct{}{} ) func trackTemp(path string) { /* add to map */ } func untrackTemp(path string) { /* remove from map */ } func Cleanup() { /* os.Remove all tracked files */ } ``` In `main.go`: `defer modulepackage.Cleanup()` for every module that exports Cleanup. ## 5. BUBBLES v1.0.0 API REFERENCE ```go // textinput ti := textinput.New() ti.Placeholder = "..." ti.Focus() ti.CharLimit = 50 ti.Width = 40 m.textInput, cmd = m.textInput.Update(msg) textinput.Blink // tea.Cmd for cursor blink // spinner s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) m.spinner, cmd = m.spinner.Update(msg) // on spinner.TickMsg m.spinner.Tick // tea.Cmd // list l := list.New(items []list.Item, delegate list.ItemDelegate, width int, height int) l := list.New(items, list.NewDefaultDelegate(), 0, 0) l.Title = "..." l.SetShowStatusBar(false) l.SetFilteringEnabled(false) l.SetShowHelp(true) l.SetItems(items) l.SetSize(w, h) l.SelectedItem() list.Item l.FilterState() list.FilterState // Filtering, Unfiltered, FilterApplied m.list, cmd = m.list.Update(msg) // progress p := progress.New(progress.WithDefaultGradient()) p.Width = 40 p.SetPercent(float64) tea.Cmd // returns tea.Cmd p.View() string // Handle progress.FrameMsg in Update, forward via: pm, cmd := m.progress.Update(msg) m.progress = pm.(progress.Model) return m, cmd // generic tea.Batch(cmd1, cmd2, ...) tea.Cmd // run commands in parallel tea.Tick(d, func(time) Msg) tea.Cmd // fire message after duration tea.Quit tea.Cmd // exit program ``` ## 6. TEMPLATE: New Module (Minimal) ```go package newmodule import ( "fmt" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // ===== metadata ===== func (m *Model) Title() string { return "Module Name" } func (m *Model) Description() string { return "brief description" } // ===== styles ===== var ( titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")) errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true) successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) ) // ===== state machine ===== type state int const ( stateInput state = iota stateDone stateError ) // ===== messages ===== type myDoneMsg struct { result string err error } // ===== model ===== type Model struct { state state textInput textinput.Model result string errMsg string } func New() *Model { ti := textinput.New() ti.Placeholder = "..." ti.Focus() ti.CharLimit = 50 ti.Width = 40 return &Model{ state: stateInput, textInput: ti, } } func (m *Model) Init() tea.Cmd { return textinput.Blink } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: // resize components if needed } switch m.state { case stateInput: return m.updateInput(msg) case stateDone, stateError: return m.updateDone(msg) } return m, nil } func (m *Model) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "enter": // process input m.state = stateDone return m, nil } } var cmd tea.Cmd m.textInput, cmd = m.textInput.Update(msg) return m, cmd } func (m *Model) updateDone(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } func (m *Model) View() string { switch m.state { case stateInput: return titleStyle.Render("Module Name") + "\n\n" + "Enter something:\n\n" + m.textInput.View() + "\n\n" + helpStyle.Render("Enter · 确认 Esc · 返回菜单") case stateDone: return successStyle.Render("OK!") + "\n\n" + m.result case stateError: return errorStyle.Render("Error!") + "\n\n" + m.errMsg } return "" } ``` ## 7. TEMPLATE: Module with List Selection ```go package newmodule import ( "github.com/charmbracelet/bubbles/list" // ... ) type state int const ( stateSelect state = iota stateDone stateError ) type myItem struct { title string desc string } func (i myItem) FilterValue() string { return i.title } func (i myItem) Title() string { return i.title } func (i myItem) Description() string { return i.desc } type Model struct { state state list list.Model selected string errMsg string } func New() *Model { items := []list.Item{ myItem{title: "Option A", desc: "Description A"}, myItem{title: "Option B", desc: "Description B"}, } l := list.New(items, list.NewDefaultDelegate(), 0, 0) l.Title = "请选择" l.SetShowStatusBar(false) l.SetFilteringEnabled(false) l.SetShowHelp(true) return &Model{state: stateSelect, list: l} } func (m *Model) Init() tea.Cmd { return nil } func (m *Model) updateSelect(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if msg.String() == "enter" { if m.list.FilterState() == list.Filtering { return m, nil } if sel, ok := m.list.SelectedItem().(myItem); ok { m.selected = sel.title m.state = stateDone return m, nil } } } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) return m, cmd } ``` ## 8. TEMPLATE: Module with Spinner + Async Operation ```go import ( "github.com/charmbracelet/bubbles/spinner" // ... ) type state int const ( stateInput state = iota stateWorking stateDone stateError ) type workDoneMsg struct { result string err error } type Model struct { state state spinner spinner.Model errMsg string } func New() *Model { s := spinner.New() s.Spinner = spinner.Dot s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) return &Model{state: stateInput, spinner: s} } func (m *Model) Init() tea.Cmd { return m.spinner.Tick } func (m *Model) updateWorking(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case spinner.TickMsg: var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd case workDoneMsg: if msg.err != nil { m.state = stateError m.errMsg = msg.err.Error() return m, nil } m.state = stateDone return m, nil } return m, nil } // Command that runs work asynchronously func doWorkCmd() tea.Cmd { return func() tea.Msg { // blocking work return workDoneMsg{result: "ok"} } } // Trigger: when entering working state: // return m, tea.Batch(m.spinner.Tick, doWorkCmd()) ``` ## 9. TEMPLATE: Module with Download Progress (Goroutine + Ticker) For file downloads with a progress bar, use this pattern: ```go import ( "sync" "time" "github.com/charmbracelet/bubbles/progress" ) type downloadProgressMsg struct{} type downloadTracker struct { mu sync.Mutex total int64 current int64 done bool path string err error } type Model struct { // ... progress progress.Model dlTracker *downloadTracker } // Start download - opens connection, returns immediately. // Goroutine does the actual I/O and updates tracker. func startDownloadCmd(url string, tr *downloadTracker) tea.Cmd { return func() tea.Msg { resp, err := http.Get(url) // error handling... tr.total = resp.ContentLength go func() { defer resp.Body.Close() buf := make([]byte, 32*1024) for { n, _ := resp.Body.Read(buf) if n > 0 { // write to file... tr.mu.Lock() tr.current += int64(n) tr.mu.Unlock() } // check done... } tr.mu.Lock() tr.done = true tr.path = tmpFile.Name() tr.mu.Unlock() }() return downloadProgressMsg{} } } // Ticker fires every 100ms to poll tracker func tickDownload() tea.Cmd { return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg { return downloadProgressMsg{} }) } // Handle in Update: case downloadProgressMsg: m.dlTracker.mu.Lock() current, total, done, path, dlErr := m.dlTracker.current, m.dlTracker.total, m.dlTracker.done, m.dlTracker.path, m.dlTracker.err m.dlTracker.mu.Unlock() if done { if dlErr != nil { /* error */ } m.downloadedPath = path // proceed to next step... return m, tea.Batch(m.spinner.Tick, nextStepCmd()) } pct := float64(current) / float64(total) return m, tea.Batch( m.spinner.Tick, m.progress.SetPercent(pct), // returns tea.Cmd tickDownload(), ) // In View, show progress bar: // m.progress.View() // In top-level Update, handle progress.FrameMsg: if frameMsg, ok := msg.(progress.FrameMsg); ok { pm, cmd := m.progress.Update(frameMsg) m.progress = pm.(progress.Model) return m, cmd } ``` ## 10. TEMPLATE: Module with Sequential Steps (chain of async ops) Use a `stepDoneMsg` that carries the next step number: ```go const ( stepA = iota stepB stepC stepFinished ) type stepDoneMsg struct { nextStep int detail string err error } // Step command returns stepDoneMsg with the NEXT step: func stepACmd() tea.Cmd { return func() tea.Msg { // do work return stepDoneMsg{nextStep: stepB, detail: "some value"} } } func stepBCmd() tea.Cmd { return func() tea.Msg { return stepDoneMsg{nextStep: stepC} } } func stepCCmd() tea.Cmd { return func() tea.Msg { return stepDoneMsg{nextStep: stepFinished} } } // Handle in Update: case stepDoneMsg: if msg.err != nil { /* error */ } switch msg.nextStep { case stepB: return m, tea.Batch(m.spinner.Tick, stepBCmd()) case stepC: return m, tea.Batch(m.spinner.Tick, stepCCmd()) case stepFinished: m.state = stateDone; return m, nil } ``` ## 11. CHECKLIST: Adding a New Module When adding a new module, follow this exact sequence: 1. [ ] Create directory `voxy_import//` 2. [ ] Create file `.go` with `package ` 3. [ ] Implement `Title()` and `Description()` methods on `*Model` 4. [ ] Define `type state int` and `const` iota block 5. [ ] Define `type Model struct` with `state` field and necessary UI components 6. [ ] Write `func New() *Model` constructor 7. [ ] Implement `Init() tea.Cmd` 8. [ ] Implement `Update(tea.Msg) (tea.Model, tea.Cmd)` with state dispatch 9. [ ] Implement `View() string` with state dispatch 10. [ ] Implement per-state `update*` and `view*` methods 11. [ ] Define custom message types if doing async work 12. [ ] Write async command functions that return `tea.Cmd` 13. [ ] In `main.go`: add import for the new module package 14. [ ] In `main.go`: add entry to `entries` slice with `Create: func() tea.Model { return modname.New(svc) }` 15. [ ] If module creates temp files: implement `Cleanup()` and add `defer modname.Cleanup()` to `main()` 16. [ ] Run `go build -o voxy_import.exe .` — fix ALL compilation errors 17. [ ] Test: verify module appears in menu, Enter activates it, Esc returns to menu ## 12. DO NOT - Do NOT use value receivers `(m Model)` — always `(m *Model)` - Do NOT handle Esc or Ctrl+C in module Update — main.go handles these - Do NOT define `main()` in module packages - Do NOT import the main package from module packages - Do NOT export module fields — all fields lowercase - Do NOT use `tea.Batch` for sequential operations — use the stepDoneMsg chain pattern - Do NOT create new module files in the root directory — always in a sub-package - Do NOT use `fmt.Println` for logging — it breaks the TUI - Do NOT modify `go.mod` — if new deps needed, run `go mod tidy` - Do NOT use `os.Exit(1)` in modules — return error state instead