Architecture
terraform-ui is a Go application built with Bubbletea (TUI framework) and terraform-exec (terraform SDK). Features are modular plugins that depend only on a public SDK package.
Dependency Direction
plugins/* ───→ pkg/sdk ←─── internal/*
│ │
│ (public contract only) │ (implements sdk interfaces)
│ │
└── never imports internal/ ──────┘
Plugins depend exclusively on pkg/sdk/. Internal packages implement the interfaces defined there. This enables future extraction of plugins to separate repositories or gRPC-based external processes.
Project Structure
cmd/tfui/main.go — CLI entry point, plugin registration (thin glue)
pkg/sdk/ — Public SDK (the only dependency for plugins; key files below)
plugin.go — Plugin interface + optional interfaces
context.go — Shared context (service, dir, workspace)
types.go — Domain types (Action, RiskLevel, Resource, PlanChange)
service.go — Service interface (terraform operations)
bus.go — EventBus typed dispatch
events.go — Event types and handler interfaces
options.go — ResolvedOptions + BuildPlanOptions/BuildApplyOptions
frame.go, stack.go — Frame stack navigation primitives
overlay.go, input.go — Modal overlays and input system
action.go — PluginAction system (CLI/REPL-callable operations)
app_context.go — Root application state container
styles.go — Style constants for consistent rendering
internal/
config/config.go — HCL config loading, project discovery
plugin/registry.go — Plugin registry (host-side only)
terraform/
exec/
service.go — ExecService (implements sdk.Service via terraform-exec)
state_ops.go — State operations (rm, mv, import, taint, untaint)
workspace_ops.go — Workspace operations
dir_lock.go — Non-reentrant per-directory lock (ADR-0016)
macro_service.go — MacroService (records commands, reads from cache)
service_cache.go — ServiceCache (typed, source-aware data cache)
plan_parser.go — Plan JSON parsing
source/ — Universal source abstraction (URI resolution, providers)
macro/ — Macro engine (Driver, tape DSL parser)
ui/
app.go — Root Bubbletea model, plugin routing
components/ — Header, statusbar, AI panel, command bar, content border
editor/ — Editor integration ($EDITOR at file:line)
ai/ — AI provider (Claude via Bedrock, auto-detection)
logging/ — Structured logger setup
scaffold/ — Project scaffolding (tfui.hcl generation)
compliance/ — Plugin lifecycle compliance tests
plugins/
context/ — Context dashboard: Project + Chdir + Workspace
chdir/ — Chdir picker: select member from explicit list
state/ — State browser (list, inspect, pin, delete, move)
plan/ — Plan review (diff view, expand attributes, risk)
apply/ — Apply executor with replan + confirmation
taint/ — Standalone taint verb (mark for recreation)
untaint/ — Standalone untaint verb (remove taint mark)
import/ — Standalone import verb (bring existing infra)
workspace/ — Workspace management
console/ — Terraform console (REPL)
output/ — Terraform outputs viewer
validate/ — Terraform validate with diagnostics
init/ — Terraform init with form-based options
risk/ — Risk classification (decorates plan)
phantom/ — Phantom change detection (decorates plan)
blastradius/ — Blast radius visualization
forceunlock/ — Force unlock stale state locks
version/ — Version information display
tests/
integration/ — CLI integration tests (require terraform/tofu/terragrunt)
fixtures/ — Real terraform projects and config fixtures
Plugin System
Every feature is a plugin implementing pkg/sdk.Plugin:
type Plugin interface {
ID() string
Name() string
Description() string
Init(ctx *Context) tea.Cmd
Update(msg tea.Msg) (Plugin, tea.Cmd)
View(width, height int) string
Configure(cfg map[string]interface{}) error
Ready() bool
}
Plugins are registered with external metadata (PluginMeta) that controls keybinding and menu visibility — plugins themselves are invocation-agnostic.
Plugins can also implement handler interfaces (ChdirHandler, WorkspaceHandler, etc.) to receive typed events from other plugins via the EventBus. This enables reactive data loading without polling or stringly-typed session keys.
Plugins are:
- Self-contained — own view logic, types, messages
- Configurable — per-plugin options in
tfui.hcl - Independent — import only
pkg/sdk, neverinternal/ - Testable — mock the
sdk.Serviceinterface
Plugin Loading
Currently: built-in (compiled into the binary). Factories are registered in cmd/tfui/main.go and built via the registry with config.
Future (v1.1+): hashicorp/go-plugin for external plugins over gRPC. Third-party plugins as separate binaries communicating via the same sdk.Plugin contract serialized over proto.
Terraform Integration
Two sibling implementations of sdk.Service exist as a strategy pattern — selected at startup based on execution mode:
ExecService (live execution)
internal/terraform/service.go — shells out to the terraform (or tofu) binary via hashicorp/terraform-exec:
Plan()— terraform plan → parse JSON → classify risk → detect phantomsApply()— terraform apply on saved plan fileStateList(opts ...StateListOption)— parse state JSON for resource list (supportsSkipCache()to force re-read)Show()— terraform state show for resource detailWorkspace*()— workspace list, select, create, deleteWithDir()— returns a new ExecService scoped to a different directory (fresh cache)
Reads go through a ServiceCache (typed, source-aware). If the cache is pre-seeded (via -plan/-state flags), reads are served from cache without shelling out.
MacroService (recording)
internal/terraform/macro_service.go — records every operation as an sdk.Command without executing anything:
- Mutating calls (
Plan,Apply,StateRm, etc.) → record the command, return empty/cached data - Read calls (
StateList,Show,Workspace) → serve from the sameServiceCache - After macro playback, all recorded commands are printed to stdout
This makes macros deterministic and safe: the UI renders real data (from cache) but mutations never touch infrastructure.
ServiceCache (shared state)
internal/terraform/service_cache.go — both strategies share this typed cache:
- Pre-seeded at startup from
-plan/-statefile/stdin - Three source kinds:
file(re-reads on invalidate),stdin(immutable),exec(cleared on invalidate) - ExecService populates it after live calls; MacroService only reads from it
- State-mutating operations (
StateRm,StateMove,Import,Taint,Untaint) auto-invalidate state cache - Plugins can bypass cache via
StateList(ctx, sdk.SkipCache())for explicit refresh (ctrl+r)
Binary resolution
OpenTofu is supported via explicit terraform.bin = "tofu" in HCL config, or by letting terraform-exec resolve the binary from PATH. There is no auto-detection logic.
Entry Point (cmd/tfui/)
The CLI layer is split across several files:
cmd/tfui/
├── main.go — Cobra command tree, flag bindings, buildRegistry()
├── session.go — Session builder: orthogonal dispatch pipeline
├── cli.go — Imperative subcommands (workspace, force-unlock)
├── normalize.go — Flag normalization (terraform flag compatibility)
└── scaffold_wizard.go — Interactive project scaffolding wizard
Every subcommand’s RunE is a single fluent chain:
NewSession(cfg, rootCfg).
ForPlugin("plan").
WithArgs(args).
WithJSON(jsonMode).
WithSeeds(planURI, stateURI).
WithMacro(macroURI).
WithCI(ciMode).
Run()
Session pipeline (session.go)
Session.Run() executes: validate → resolveAxes → loadTape → buildService → buildApp → present → emit
Two orthogonal axes are resolved from flags:
| Axis | Values | Determines |
|---|---|---|
| Presentation | Interactive / Headless |
Whether user sees a TUI (tea.Program) or headless driver |
| Backend | Exec / Recording |
Whether terraform runs live (ExecService) or commands are recorded (MacroService) |
Architectural decisions
| Decision | Rationale |
|---|---|
| Orthogonal axes (Presentation × Backend) | Any combination is valid: Interactive+Exec (TUI), Headless+Exec (CI), Headless+Recording (macro). Flags compose, not branch. |
buildRegistry() is axis-agnostic |
Plugins don’t know their presentation or backend. Same registry regardless of which axis combination is active |
| ServiceCache pre-seeded before service creation | seedCache() runs first, then the service wraps it. Pre-seeded data serves reads without shelling out |
buildRegistry() as single composition point |
All 18 plugins registered with metadata in one place. Config injection happens here, not scattered |
| TTY detection gates interactive mode | No TTY → Headless axis. No TTY without data → actionable error |
splitPassthrough() + normalizeArgs() |
Terraform flag compatibility: -- separates tfui flags from terraform extras; short flags normalized |
| Version resolution chain | ldflags (CI) → ReadBuildInfo (go install) → "0.0.0-SNAPSHOT" (dev) |
| Spinner on stderr, data on stdout | CLI commands respect Unix conventions: pipe-safe stdout, human feedback on stderr (suppressed with -ci) |
Config Resolution & Propagation
Config is resolved from tfui.hcl at three levels: root defaults → child (member dir) → workspace-specific overrides.
Startup:
LoadRoot(projectDir) → RootConfig
config.Resolve(root, nil, workspace) → cfg.VarFiles, cfg.Vars
NewApp(..., rootCfg) → loads childCfg from workDir
Plugins receive *ResolvedOptions via Context.Options
Runtime (workspace switch):
WorkspaceChangedEvent → app.resolveOptions(name)
→ config.Resolve(rootCfg, childCfg, newWorkspace)
→ mutates shared *ResolvedOptions in-place
→ next plan/apply reads fresh VarFiles/Vars
Runtime (chdir switch):
ChdirChangedEvent → app reloads childCfg from new dir
→ app.resolveOptions(activeWorkspace)
→ same shared-pointer mutation
ExtraArgs (from CLI -- passthrough) is set once at boot and never overwritten by resolution.
Bubbletea Model/Update/View
┌────────────────────────────────────────┐
│ App (root model) │
│ - routes key presses to active plugin │
│ - handles global keys (q, Esc) │
│ - manages window resize │
├────────────────────────────────────────┤
│ Plugin (active) │
│ - receives Update() delegation │
│ - returns View() for content area │
│ - Init() for async data loading │
├────────────────────────────────────────┤
│ Components (header, statusbar) │
│ - shared UI chrome │
│ - rendered by App around plugin view │
└────────────────────────────────────────┘
Testing Strategy
| Layer | Approach | Coverage Target |
|---|---|---|
pkg/sdk |
Unit tests (bus, actions, stack, pins, options) | 100% |
internal/terraform (parsers) |
Unit tests, mock data | 100% |
internal/terraform (service calls) |
Integration tests (need terraform binary) | Excluded from unit coverage |
internal/plugin |
Unit tests, mock plugins | 100% |
internal/ui |
Unit tests, mock registry | 100% |
plugins/* |
Unit tests, mock service | 100% |
cmd/tfui |
Excluded (thin glue) | N/A |
tests/integration |
Integration (build tag, need terraform) | Behavioral coverage |
Coverage enforcement: 100% gate on all packages except cmd/ (glue layer) and terraform-exec I/O calls. Pipeline fails if coverage drops.
Development
mise run dev # Launch TUI in dev mode
mise run build # Cross-platform binaries
mise run fmt # Format source files
mise run check:lint # Lint (golangci-lint v2)
mise run test:unit # Unit tests
mise run test:coverage # Coverage enforcement (100%)
mise run 'test:integration:*' # Integration tests (terraform/tofu/terragrunt)
mise run test:macro # Macro tapes
Key Design Decisions
Architecture
| Decision | Rationale |
|---|---|
pkg/sdk as public contract |
Plugins never import internal/. Enables future extraction and gRPC plugins. |
| Plugin = feature | Every capability is a plugin. Core app is just routing. |
| Dependency injection | Service interface enables mocking. Plugins receive context, not concrete types. |
| Plugins invocation-agnostic | Same plugin works via keybinding, command bar, CLI, macro. No coupling to navigation. |
| hashicorp/go-plugin (future) | Third-party plugins as separate binaries over gRPC. Same interface, different transport. |
| 100% coverage (excl cmd/) | Pipeline enforced. Untestable layers kept minimal via DI. |
| OpenTofu first-class | Configurable via terraform.bin in HCL config, or let terraform-exec resolve. No auto-detection. Test matrix includes both. |
EventBus typed pub/sub |
Plugins react to state changes (chdir, workspace, plan) via handler interfaces. No polling, no stringly-typed keys. Compile-time safe. |
| ExecService + MacroService as sibling strategies | Same interface, different behavior. ExecService shells out for live ops; MacroService records commands without executing. Selected at startup, plugins are unaware. |
| ServiceCache as shared read layer | Both strategies read from the same cache. Pre-seeded from -plan/-state at startup, populated by ExecService after live calls. Pre-seeding means reads never shell out when data is already available. |
UX
| Decision | Rationale |
|---|---|
| Plan and Apply are separate screens | Different cognitive purposes (review vs execute). Apply can run standalone. Can take 10+ minutes. |
| Pins = targets | Visual selection (space) is the TUI equivalent of -target. Pin then apply = apply only pinned. |
a from Plan = apply with pins |
Seamless flow: review → select → execute. No address typing. |
| ExecService executes live, user waits for real results | Spinners, elapsed time, real errors — the UX communicates “this is happening now” with progress feedback and retry on failure. |
| MacroService returns commands, not errors | Macro playback becomes a command builder. User sees the terraform commands that would run. |
| CLI and TUI produce identical state | Testable invariant. The equivalence guarantee. |
| Macros test UI, integration tests outcomes | Different concerns, different tools. Macros are fast/deterministic. Integration tests are authoritative. |
Command Type (pkg/sdk/command.go)
Every service operation maps to a sdk.Command:
type Command struct {
Binary string // "terraform" or "tofu"
Verb string // "plan", "apply", "state rm", etc.
Args []string // positional (addresses, IDs)
Flags []string // flags like "-target=X"
Dir string // working directory
}
This makes the tool’s relationship to terraform explicit: tfui is a command builder with a visual interface. The TUI helps you construct the right terraform command. The CLI gives you a concise alternative. Both produce the same command, same outcome.
In macro mode, mutating operations are recorded as commands — the user sees what they’d need to run:
terraform state rm aws_instance.old
terraform apply -target=aws_instance.web
Data Flow: Plan → Pin → Apply
User presses 'p' → Plan plugin activates
→ svc.Plan(ctx, nil) → terraform plan
→ Shows changes with risk badges
User presses 'space' on resources → pins via shared PinService
User presses 'a' → Plan emits ApplyRequestMsg
→ plan emits PlanCompletedEvent
→ App reads pins from PinService
→ Activates Apply plugin with pins as targets
→ Re-plans with -target (only pinned)
→ Shows confirmation: "Apply 2 of 12 changes?"
User presses 'y' → svc.Apply(ctx, targets)
→ terraform apply (targeted plan)
→ Shows elapsed time → success/error
Table of contents
- ADR-0001: SDK isolation: plugins import only pkg/sdk
- ADR-0002: Dual runtime strategy: ExecService and MacroService
- ADR-0003: Typed event bus for inter-plugin communication
- ADR-0004: Navigation: Replace vs Push with return destination
- ADR-0005: Cache pre-seeding via -plan and -state flags
- ADR-0006: Orthogonal axes: income and outcome are independent
- ADR-0007: No cache semantics in the SDK contract
- ADR-0010: tfui is a command builder, not a terraform abstraction
- ADR-0011: Batch operations only through ! palette
- ADR-0012: Broadcast result messages to all plugins
- ADR-0013: Plugins own their operation context
- ADR-0014: Actions bar — separate terraform mutations from UI hints
- ADR-0015: Axis-split box model for content rendering
- ADR-0016: Non-reentrant directory lock for terraform CLI serialization
- ADR-0017: Novel flags must be orthogonal — no conditional validity
- ADR-0020: PlanFile lifecycle ownership
- ADR-0008: 100% coverage as a behavioral forcing function
- ADR-0009: Terraform's command namespace is reserved
- ADR-0018: Atomic Context replacement on context switch
- ADR-0019: Unidirectional data flow