Files
arinera-minecraft-tool/AGENTS.md
2026-05-27 19:31:47 +08:00

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.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:

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:

  1. Create directory voxy_import/<modulename>/
  2. Create file <modulename>.go with package <modulename>
  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