22 KiB
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/<name>/ — 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:
type Module interface {
tea.Model
Title() string
Description() string
}
A module MUST implement:
Init() tea.CmdUpdate(tea.Msg) (tea.Model, tea.Cmd)View() stringTitle() string— shown in menu listDescription() 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:
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:
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
// 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:
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).
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
type state int
const (
stateInput state = iota // first state = initial
stateProcessing
stateDone
stateError // always last (shared handler with stateDone)
)
State dispatcher in Update():
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():
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
// 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:
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):
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
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:
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
// 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)
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
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
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:
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:
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:
- Create directory
voxy_import/<modulename>/ - Create file
<modulename>.gowithpackage <modulename> - Implement
Title()andDescription()methods on*Model - Define
type state intandconstiota block - Define
type Model structwithstatefield and necessary UI components - Write
func New() *Modelconstructor - Implement
Init() tea.Cmd - Implement
Update(tea.Msg) (tea.Model, tea.Cmd)with state dispatch - Implement
View() stringwith state dispatch - Implement per-state
update*andview*methods - Define custom message types if doing async work
- Write async command functions that return
tea.Cmd - In
main.go: add import for the new module package - In
main.go: add entry toentriesslice withCreate: func() tea.Model { return modname.New(svc) } - If module creates temp files: implement
Cleanup()and adddefer modname.Cleanup()tomain() - Run
go build -o voxy_import.exe .— fix ALL compilation errors - 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.Batchfor 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.Printlnfor logging — it breaks the TUI - Do NOT modify
go.mod— if new deps needed, rungo mod tidy - Do NOT use
os.Exit(1)in modules — return error state instead