Skip to content

TUI refactor#1547

Merged
dgageot merged 10 commits intodocker:mainfrom
dgageot:tui-refactor
Feb 1, 2026
Merged

TUI refactor#1547
dgageot merged 10 commits intodocker:mainfrom
dgageot:tui-refactor

Conversation

@dgageot
Copy link
Member

@dgageot dgageot commented Jan 31, 2026

No description provided.

@dgageot dgageot requested a review from a team as a code owner January 31, 2026 08:54
Split the flat messages.go file into domain-specific files:
- session.go: Session lifecycle (new, exit, save, load, etc.)
- theme.go: Theme selection and preview
- agent.go: Agent switching and model selection
- toggle.go: UI state toggles (YOLO, thinking, sidebar)
- input.go: Editor input, attachments, and speech
- mcp.go: MCP prompt interactions

This follows the Elm Architecture principle of grouping messages
by the domain they affect, making it easier to understand which
components handle which messages.

Assisted-By: cagent
Extract the collapsed layout computation into a pure data structure
(CollapsedViewModel) and move rendering to a standalone function
(RenderCollapsedView).

This follows the Elm Architecture principle of keeping Model (data)
separate from View (rendering). The ViewModel pattern makes the
data-to-view transformation explicit and testable:

- CollapsedViewModel: pure data with layout decisions
- RenderCollapsedView: pure function that renders the view model

The model's computeCollapsedViewModel() builds the view model,
and the view calls RenderCollapsedView() with it.

Assisted-By: cagent
Add a SessionStateReader interface that provides read-only access to
session state. Tool components that only need to read state (not
modify it) now depend on this interface rather than the concrete
*SessionState type.

This follows the Interface Segregation Principle (ISP) and makes
data flow more explicit:
- Tool components receive SessionStateReader (read-only)
- Only components that need to mutate state get *SessionState

Benefits:
- Clearer contracts about what components can do
- Easier to reason about state changes
- Better alignment with Elm's unidirectional data flow

Assisted-By: cagent
Add an animation.Subscription type that encapsulates the animation
tick registration lifecycle. This provides several benefits:

1. Clearer ownership: Components own their subscription state
2. Safer API: Start()/Stop() are idempotent and safe to call multiple times
3. Better encapsulation: Components don't need to track registration state separately

The Subscription type follows the Elm Architecture principle of making
subscriptions explicit and declarative. Components declare they need
animation ticks by holding a Subscription, rather than making imperative
calls to global functions.

Updated Spinner to use the new Subscription type as a demonstration
of the pattern.

Assisted-By: cagent
Add a Registration type and RegisterAll method to the tool registry
that makes the declarative nature of tool-to-component mapping more
explicit.

Changes:
- Move Registration type from factory to registry (single source of truth)
- Add RegisterAll() for bulk declarative registration
- Update factory to use the cleaner API

This follows the principle of making configuration explicit and
declarative, similar to Elm's approach where mappings are data
structures rather than imperative code.

Assisted-By: cagent
Add a HitTest type that centralizes all mouse hit-testing logic in one
place. This separates the concerns of:
1. What UI element is at a given coordinate (HitTest.At)
2. What to do when that element is clicked (handleMouseClick)

The MouseTarget enum makes the possible targets explicit, and the
At() method checks them in priority order. This follows the Elm
pattern of making the domain explicit through types.

Benefits:
- Single source of truth for clickable regions
- Easier to understand and modify hit areas
- Clear priority ordering when regions overlap
- Better testability of hit detection logic

Assisted-By: cagent
Move titleInput.SetWidth() calls from View rendering methods to
SetSize(), eliminating side effects from view functions.

Before: SetWidth was called during computeCollapsedViewModel() and
sessionInfo(), which are called from View(). This violated the Elm
Architecture principle that view should be a pure function.

After: SetWidth is called once in SetSize() via updateTitleInputWidth(),
and View methods simply render the pre-configured input.

Benefits:
- View() is now a pure function (no state mutations)
- Width calculation happens at the right time (on resize)
- Easier to reason about when state changes occur
- Better performance (SetWidth not called on every render)

Assisted-By: cagent
Add a Builder type that provides a fluent API for accumulating tea.Cmd
values, reducing boilerplate in Update functions.

Features:
- Add(cmd): append a single command (nil-safe)
- AddIf(cond, cmd): conditionally append
- AddAll(cmds...): append multiple commands
- Batch(): return batched command (optimizes single/empty cases)

This follows the Elm Architecture pattern of accumulating effects
(commands) during the update phase, making the intent clearer:

  Before:
    var cmds []tea.Cmd
    cmds = append(cmds, cmd1)
    if condition { cmds = append(cmds, cmd2) }
    return m, tea.Batch(cmds...)

  After:
    return m, cmdbatch.New().
        Add(cmd1).
        AddIf(condition, cmd2).
        Batch()

Assisted-By: cagent
Restructure runtime_events.go to group event handlers by category,
making the event-to-update mapping more explicit and easier to navigate.

Categories:
- Error/Warning Events: Error display and notifications
- Stream Lifecycle: Start/stop spinners, pending state
- Content Events: Text appending, reasoning blocks
- Tool Events: Tool call lifecycle (partial → running → response)
- Sidebar Info Events: Agent/team/toolset info updates
- RAG Indexing Events: Forwarded to sidebar
- Dialog Events: Max iterations, elicitation dialogs

Also added documentation header explaining the event mapping pattern,
following the Elm Architecture principle of making the event-to-update
relationship explicit and discoverable.

Assisted-By: cagent
Add a subscription package that provides patterns for converting
external event sources (channels, etc.) into the Bubble Tea message
flow, following the Elm Architecture subscription pattern.

Features:
- FromChannel: Convert channel reads to tea.Cmd/tea.Msg
- FromChannelWithClose: With optional close handler
- ChannelSubscription: Reusable wrapper with Listen() method

Updated the theme file watcher to use ChannelSubscription, making
the external event source explicit and demonstrating the pattern.

In Elm, subscriptions are declared at the top level and the runtime
manages them. This package provides similar patterns for Go/Bubble Tea,
making external event handling explicit and consistent.

Assisted-By: cagent
@krissetto
Copy link
Contributor

/review

Copy link
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TUI Refactor Review

Found 1 high-severity bug in the animation subscription refactoring.

✅ Good Refactoring

  • Clean separation of concerns with view models
  • SessionStateReader interface follows principle of least privilege
  • Message organization by domain is much clearer
  • HitTest centralization improves maintainability

🐛 Critical Bug Found

The spinner animation subscription has a resource leak due to value receiver semantics - see inline comment for details and fix.

@@ -119,13 +120,13 @@ func (s Spinner) SetSize(_, _ int) tea.Cmd { return nil }
// Init registers the spinner with the animation coordinator.
// If this is the first active animation, it starts the global tick.
func (s Spinner) Init() tea.Cmd {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 HIGH SEVERITY: Resource leak - animation subscriptions not properly managed

The Init() and Stop() methods use value receivers, which means they operate on copies of the Spinner struct. This breaks the subscription lifecycle:

The Problem:

  1. Init() calls s.animSub.Start() which sets active=true on a copy of the Spinner
  2. The original spinner's animSub.active remains false (changes are lost)
  3. Later, Stop() operates on another copy where active=false
  4. Stop() sees active=false and returns early without calling Unregister()

The Impact:

  • Animation coordinator's register count is incremented but never decremented
  • Animation ticks continue running even when no spinners are active
  • Memory and CPU leak that accumulates over time

The Fix:
Change both methods to use pointer receivers:

func (s *Spinner) Init() tea.Cmd {
    return s.animSub.Start()
}

func (s *Spinner) Stop() {
    s.animSub.Stop()
}

Alternatively, if value receivers are required for Bubble Tea compatibility, the Spinner should be stored by pointer everywhere it's used (e.g., in toolcommon/base.go).

Copy link
Contributor

@krissetto krissetto Jan 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems irrelevant looking better at the changes, wdyt? @dgageot

Copy link
Contributor

@krissetto krissetto left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also gave this a test on one of my slower machines

@dgageot dgageot merged commit 3d9f7b3 into docker:main Feb 1, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants