GUI vs TUI in Go: fyne and crush#

Summary#

Comparing fyne and crush exposes the full spectrum of what “UI” means in Go: a mature, CGo-dependent OpenGL framework with a seven-year multi-backend architecture vs. a recent, CGo-free terminal application built on the Charmbracelet ecosystem’s reactive message loop. Despite both rendering visuals to users, the two projects share almost no architectural mechanisms — they have converged only on Go idioms (manual DI, interface-driven extensibility, testify) while diverging on rendering model, state management, event routing, context usage, and testing strategy. The comparison is most instructive not for what the projects have in common but for what the GUI vs. TUI choice forces each one to be.


Comparison dimensions#

Rendering model#

ProjectApproachStrengthsWeaknesses
fyneOpenGL/GLFW on desktop; software (CPU) renderer for headless. Build-tag backend selection at compile time. Pixel-level control via GLSL shaders.True native rendering; platform-quality text, images, animation. Software renderer enables CI without GPU.CGo dependency; C compiler required; first Windows build can take 10 minutes. No GPU = no hardware acceleration on server environments.
crushTerminal character grid via BubbleTea/Lipgloss. “Rendering” is string generation: View() string returns ANSI escape sequences. No GPU, no CGo for rendering itself.Zero native dependencies for rendering; ships a single static binary; runs in any SSH session or CI. Cross-platform without build tags.Limited to what terminals can display: fixed-width glyphs, 256/truecolor ANSI, no arbitrary shapes. Cannot do pixel-accurate images, smooth animation, or sub-character positioning.

Narrative: Fyne’s rendering model is the dominant architectural driver. The requirement to produce pixel-accurate output on OpenGL, WASM, mobile, and a headless software renderer forces the entire three-layer architecture (interfaces → composition → backend). Everything — the CanvasObject tree, the renderer cache, the lock-free refresh queue, the build-tag dispatch — exists to serve the rendering pipeline.

Crush’s rendering model is fundamentally simpler: BubbleTea’s View() method returns a string. Lipgloss handles ANSI color codes and sizing. There is no cache, no shader pipeline, no GPU texture management. The architectural complexity in crush lives not in rendering but in the concurrent service layer (agent loops, pub-sub, LSP, MCP, SQLite persistence) that the terminal UI merely displays.

This single choice — pixel vs. character — cascades into almost every other difference below.


Component / widget model#

ProjectModelLifecycleExtensibility
fyneWidget/WidgetRenderer split. Widgets own state; WidgetRenderer owns visual representation. One renderer cached per widget instance in internal/cache. Renderers are recreated on theme change via Destroy() + CreateRenderer().Object-oriented class hierarchy: all widgets embed internal/widget.Base. MinSize(), Resize(), Move(), Refresh() defined by the base.Third parties implement fyne.Widget + CreateRenderer(). Compiler enforces the contract.
crushBubbleTea Model interface: Init() Cmd, Update(Msg) (Model, Cmd), View() string. Root ui/model.Model composes sub-models (chat, diffview, completions, permissions dialog, attachments, etc.) as struct fields. State is immutable between updates — Update returns a new model, not mutation.Elm-architecture lifecycle: Init() fires startup commands; Update() handles every message; View() renders current state. No inheritance; composition via embedding sub-models.Sub-models are structs with their own Update/View. Adding a new UI pane = adding a field and routing messages to its Update. No framework contract beyond satisfying tea.Model.

Narrative: Fyne’s widget model is class-based and mutation-friendly. Setting button.SetText("Clicked") mutates the widget in place and calls Refresh() — an imperative style aligned with traditional OO GUI frameworks (Qt, Swing). The Widget/WidgetRenderer split is Fyne’s distinctive contribution: it separates stateful identity from heavyweight GPU resources, enabling renderer recreation without losing widget state.

Crush’s model is functional and immutable: every user action or backend event produces a new model value. This is BubbleTea’s architectural constraint (the Elm architecture), not a choice crush made independently. The benefit is that Update() is a pure function — the entire TUI state at any point is the result of replaying all messages, which makes testing and reasoning straightforward. The cost is boilerplate message routing: every sub-model must be explicitly wired into the root Update() dispatch.


State management#

ProjectApproachBindingThread safety
fyneDirect mutation of exported widget fields (entry.SetText(...), binding.String.Set(...)). Observable state via data/binding: DataItemAddListener(DataListener)DataChanged().Reactive: NewEntryWithData(binding.String) connects a widget to a DataItem; changes propagate via fyne.Do() to the UI thread.All mutations routed through fyne.Do(fn) marshal to the main OS thread. Raw mutations from non-UI goroutines are unsafe.
crushImmutable: BubbleTea Update(Msg) (Model, Cmd) replaces the entire model on every event. Backend state (sessions, messages, agent status) lives in services; the TUI receives snapshots via tea.Msg events.Event-driven: domain services publish events to pubsub.Broker[tea.Msg]; the TUI subscription goroutine calls program.Send(msg) which triggers a new Update() cycle.BubbleTea serializes all Update() calls on its own goroutine; backend services use sync.RWMutex-protected generic collections (csync.Map, csync.Slice). No equivalent of fyne.Do() needed.

Narrative: The most practical difference for application authors is how each framework solves the multi-goroutine UI update problem.

Fyne requires explicit thread marshaling: code running in a goroutine that wants to update a widget must wrap the call in fyne.Do(func() { label.SetText("updated") }). Forgetting fyne.Do() is a common bug — it produces subtle race conditions that pass -race because the flaw is in goroutine scheduling, not data access.

Crush eliminates this problem by making the TUI single-owner: program.Send(msg) is the only way to trigger a UI update, and BubbleTea serializes all Update() calls. The developer never thinks about the UI goroutine because there is no concept of “running code on the UI goroutine” — you send a message and let BubbleTea route it. The cost is that state changes require constructing and routing typed message values rather than calling a setter directly.


Event handling#

ProjectInput sourceRouting mechanismUser code entry point
fyneGLFW OS callbacks (keyboard, mouse, touch). Translated to fyne.PointEvent / fyne.KeyEvent. Pushed into UnboundedChan[func()] event queue.Canvas.hitTest() → widget at point → method dispatch (Tapped, KeyDown, Focused).Callback fields on widgets: button.OnTapped = fn, entry.OnChanged = fn. One callback per event type per widget.
crushTerminal keypresses + resize signals via BubbleTea’s I/O loop. Backend events via pubsub.Broker[tea.Msg]program.Send(msg).Single Update(msg tea.Msg) method on the root model. Type switch over tea.Msg dispatches to sub-model Update() calls.BubbleTea Update(msg) (Model, Cmd). All events flow through the same function regardless of origin.

Narrative: Fyne’s event model is widget-centric: each widget registers callbacks for the events it cares about. This maps well to imperative GUIs where widgets have stable identities over time. The downside is event proliferation — adding a new event type to the system (e.g., Accessible) requires a new interface in the root package and updates to every widget that wants to handle it.

Crush’s BubbleTea model is function-centric: a single Update function handles everything. This is more testable (pass a tea.Msg, assert the returned model) but requires significant boilerplate to route messages through composed sub-models. The Update() function in ui/model/ui.go is correspondingly large — it dispatches over the entire event vocabulary of the TUI.

One notable contrast: context.Context usage. Fyne uses context.Context exactly 5 times, all in the cmd/fyne CLI tool. Crush uses it 584 times across all layers. This reflects the fundamental difference between GUI and TUI architectures: Fyne’s “context” is the main OS thread itself (fyne.Do() is the cross-goroutine primitive); crush’s concurrent service layer requires conventional Go context propagation for cancellation across network calls, LLM API calls, LSP connections, and graceful shutdown.


Testing approach#

ProjectStrategyPrimary patternCI approach
fyneHeadless software renderer (driver/software) + exported test/ package with fake App/Driver/Canvas.XML markup golden files (647 assertions). PNG pixel golden files for painter tests. Table-driven tests for logic.62% coverage floor enforced. GLFW tests use xvfb on Linux; excluded on macOS/Windows.
crushReal SQLite in temp dirs. VCR cassette replay for LLM API calls. BubbleTea golden snapshots for TUI rendering.VCR cassettes for agent integration tests; golden files for diffview; table-driven + t.Parallel() for unit tests.-race on all platforms. No coverage floor, but aggressive parallelism (376 t.Parallel() calls).

Narrative: Both projects solve their hardest testing problem by recording and replaying expensive operations.

Fyne’s hardest problem is GPU-dependent rendering: solved with the software painter (100% CPU rendering that satisfies the same gl.Painter interface) plus XML golden files that capture widget tree structure rather than raw pixels. The exported test/ package is a first-class architectural commitment — third-party widget authors test their widgets with the same infrastructure as the framework itself.

Crush’s hardest problem is LLM non-determinism: a live API call on every test run would be slow, costly, and flaky. Solved with VCR cassette replay — record real API responses once, replay deterministically forever. This is the correct engineering answer for any project that integrates with non-deterministic external APIs.

The two projects also differ in coverage philosophy: fyne enforces a 62% floor (modest given the headless infrastructure); crush has no floor but compensates with race detection on all three CI platforms and aggressive test parallelism. Neither approach is obviously better — they reflect the different maturity levels and team cultures.


Dependency footprint#

ProjectDirect depsCGoKey dependenciesRationale
fyne35Yes (go-gl/gl, go-gl/glfw, fyne-io/gl-js)OpenGL bindings, GLFW, FreeType (font rendering), Noto fonts, go-gl/glfwCGo is unavoidable for GPU access. Font rendering and image scaling require native libs.
crush73Minimal (ncruces/go-sqlite3 is CGo-free WASM SQLite)BubbleTea v2, Lipgloss v2, Glamour, Cobra, charm.land/fantasy (LLM), ncruces/go-sqlite3, goose, sqlc, modelcontextprotocol/go-sdkCGo-free by design. Higher dep count reflects the broader integration surface (LLM providers, MCP, LSP, SQLite, OAuth).

Narrative: Fyne’s 35 direct dependencies are deceptively few — each carries significant transitive weight (OpenGL headers, GLFW, FreeType). The CGo dependency is the framework’s greatest portability cost: cross-compilation requires a C toolchain for the target platform, WebAssembly requires a separate GL binding, and go install is insufficient without platform prerequisites.

Crush’s 73 dependencies are almost entirely pure Go. The choice of ncruces/go-sqlite3 (WASM-embedded SQLite, CGo-free) over the more common mattn/go-sqlite3 (CGo) is architecturally motivated: it keeps the binary CGo-free while retaining full SQLite functionality. The higher dep count is the cost of crush’s broader integration surface — LLM provider SDKs, MCP support, LSP, OAuth flows — none of which fyne needs.


Common patterns#

Despite their differences, both projects share:

  1. Manual dependency injection throughout. Neither uses Wire, dig, or fx. Both pass dependencies explicitly through constructors. Both noted as deliberate choices given shallow, well-understood dependency graphs.

  2. Interface-driven extensibility at the boundary. Fyne’s extension points (custom widgets, custom themes, URI repositories, cloud providers, embedded drivers) are all Go interfaces. Crush’s Workspace interface is the single frontend↔backend seam — both TUI and crush run talk to the same interface, transparent to whether it’s in-process or HTTP.

  3. Build-time platform selection. Fyne uses build tags extensively for backend selection. Crush uses them more sparingly (CGo-free SQLite driver selection), but both treat the Go build system as a configuration mechanism rather than runtime switching.

  4. Single-responsibility test helpers. Fyne exports fyne.io/fyne/v2/test for third-party widget authors. Crush exposes config.NewTestStore() in production code for test setup. Both reflect a conviction that testing should be easy at the integration level, not just the unit level.

  5. Observer/event pattern for UI updates. Fyne: data/binding.DataItem.AddListener(DataListener). Crush: pubsub.Broker[tea.Msg]program.Send(). Both funnel state changes into a single serialized UI update path to avoid data races.

  6. Testify (assert + require) as the assertion library. Both projects use github.com/stretchr/testify exclusively — no gomock, ginkgo, or other frameworks. This is the de facto standard for Go projects in the corpus.


Divergent choices#

Context philosophy#

The 5 vs. 584 context.Context count is the single most quantitatively striking divergence. It reflects a fundamental architectural difference: Fyne models the entire application as a main-thread event loop (GUI convention, followed by Qt, Cocoa, GTK); crush models it as a concurrent service mesh where every blocking operation is cancellable (modern Go microservice convention). These are not stylistic choices — they are consequences of the underlying computational model.

State mutation model#

Fyne’s mutation-in-place (widget fields, SetText()) is imperative and familiar to GUI developers; crush’s immutable message-passing is functional and familiar to Elm/React developers. Neither is universally superior — mutation is ergonomic for local state changes; immutability is superior for complex event sourcing and testability.

Backend selection#

Fyne: compile-time selection via build tags. Crush: runtime selection via CRUSH_CLIENT_SERVER environment variable. Fyne’s choice keeps binaries lean; crush’s choice enables optional process isolation without recompilation.

Extension model#

Fyne has five formal extension points (custom widgets, themes, URI repositories, cloud providers, embedded drivers) — all compiled in. Crush’s primary extension mechanism is MCP (Model Context Protocol): external processes connected at runtime via JSON-RPC. Fyne’s extensions are Go code; crush’s extensions are language-agnostic external services.

Age and design philosophy#

Fyne (7 years old, Go 1.19 module) shows the evolution of a mature framework: 982 Deprecated: annotations, 768 Since: markers, careful backward compatibility, a two-step migration from v1. Crush (2025, Go 1.26.2 with GOEXPERIMENT=greenteagc) shows what a green-field Go project looks like in the current decade: Go 1.23 range iterators, generic concurrency primitives, no legacy API surface, aggressive dependency on unreleased Go versions.


AI-assisted development signals (crush)#

The P51-crush--ai-development-profile.md analysis identified three signals that survive alternative-explanation scrutiny:

1. Single-commit open-sourcing (notable residual, ambiguous) The entire crush codebase — 103,192 lines across 844 files — arrived in one public commit titled chore(legal): @carlosgrillet has signed the CLA. No other project in the 50-project corpus was open-sourced this way. The AI-consistent interpretation is batch generation from a specification; the equally plausible alternative is internal private development followed by a single release push. The signal cannot be resolved without access to the private repository history.

2. Global error-handling consistency (moderate residual) All 653 fmt.Errorf calls use %w. Across 80,500 lines of code spanning 45+ packages, no observable deviation was found. Human teams of this size typically produce package-level variation in error style. The uniformity is the signal, though the 2025 date and Charmbracelet’s engineering discipline reduce the residual.

3. AGENTS.md as a first-class artifact (meta-evidence) The repository includes a 183-line AGENTS.md structured explicitly as a development guide for AI coding assistants. This does not prove AI wrote the initial code; it documents that the development process is explicitly AI-collaborative going forward.

Do these signals affect the architectural comparison?

The error-handling uniformity (moderate residual) is an incidental quality signal — it means the API surface reads as consistent and predictable throughout, which is a positive property regardless of origin. It does not change how we read the Workspace interface design, the pubsub architecture, or the BubbleTea composition — all of which are better explained by domain conventions and team expertise.

The single-commit signal is more architecturally interesting: the features that might be deferred in iterative human development (the full REST API with Swagger annotations, the client/server split, the crush run non-interactive mode, the generic csync library) were all present from day one. If AI-assisted batch generation explains this, the lesson is that AI sessions tend to implement the full specification rather than an MVP — completeness at first delivery vs. human iteration that prioritizes the critical path.

All other signals (interface design, test patterns, dependency choices, naming) were assessed as none to weak residual — better explained by BubbleTea framework conventions, Charmbracelet ecosystem loyalty, and team expertise. The profile explicitly notes that hand-rolled fakes over generated mocks in the test suite is anti-AI (AI tends to generate boilerplate mock code eagerly) and is a mark of intentional design.


Book angle#

The GUI vs. TUI angle#

The fyne/crush comparison is the sharpest illustration of the claim that “Go can build real UIs.” Each project answers a different question: fyne answers “can Go build cross-platform native GUIs?” (yes, at the cost of CGo and rendering complexity); crush answers “can Go build complex, stateful terminal applications?” (yes, and the complexity budget goes to the application layer, not rendering).

For the book, this comparison is best framed as a trade-off matrix: a Fyne application inherits significant rendering infrastructure but gains pixel-accurate cross-platform UIs; a BubbleTea application inherits significant state-management infrastructure (the Elm architecture) but gains deployment simplicity and integrates naturally into CLI toolchains.

The architectural lesson is about where complexity lives. Fyne pushes complexity down into the rendering pipeline; the widget model is deliberately simple (imperative, mutation-in-place). BubbleTea pushes complexity up into application state (immutable, message-driven); the rendering is trivially simple (return a string). Choosing between them is choosing where you want your architectural debt.

The AI-assisted development angle#

Crush is a rich case study for what AI-assisted Go development looks like at the M tier — not because the signals are definitive, but because they are suggestive and the meta-evidence (AGENTS.md) is explicit. The book can make several craft observations without claiming certainty:

  1. Consistency as a quality dimension. The error-handling uniformity is genuinely useful for maintainers regardless of origin. If AI assistance produces more consistent first drafts, that is a feature worth discussing — human teams might deliberately apply AI to normalize legacy inconsistency.

  2. AGENTS.md as a new artifact class. The repository’s explicit AI maintenance guide is a new category of engineering document. Neither a README for humans nor a CONTRIBUTING.md for open-source contributors, it is an architecture brief for AI agents that will maintain the code. Projects adopting AI-assisted workflows will need this artifact; crush is an early, well-executed example.

  3. Completeness at first delivery. Whether or not AI produced crush’s initial code, the project demonstrates what it looks like when a system ships with its full integration surface (REST API, MCP, LSP, client/server split, non-interactive mode) from the first release. This is increasingly the expectation for developer tools — the question for the book is whether AI makes completeness-at-launch achievable for solo developers or small teams who would otherwise defer these “advanced” features.

  4. The framework still matters. The most structurally significant elements of crush — the BubbleTea composition model, the Workspace interface seam, the generic pubsub.Broker, the VCR cassette testing strategy — are domain-appropriate and well-engineered choices, not AI defaults. They reflect the Charmbracelet team’s design vocabulary. AI assistance (if present) contributed consistency and completeness; the architectural judgment was human.