Skip to content

Improve event system structure and add reusable TUI components#50

Open
silv-io wants to merge 12 commits intomainfrom
silv-io/flc-369
Open

Improve event system structure and add reusable TUI components#50
silv-io wants to merge 12 commits intomainfrom
silv-io/flc-369

Conversation

@silv-io
Copy link
Member

@silv-io silv-io commented Feb 25, 2026

Motivation

The current event system has fragmented types (LogEvent, WarningEvent) that don't scale well as we add more UI feedback patterns. We also lack reusable TUI components for common patterns like spinners and structured error display.

This PR introduces a unified event system that makes it easier to:

  • Distinguish between informational messages, successes, warnings, and notes
  • Show/hide loading spinners with explicit control
  • Display rich, structured errors with recovery actions

Changes

New event types in internal/output/:

  • MessageEvent with severity levels (Info, Success, Note, Warning) — replaces LogEvent/WarningEvent
  • SpinnerEvent for explicit spinner start/stop control
  • ErrorEvent for structured errors with title, summary, detail, and suggested actions

New TUI components in internal/ui/components/:

  • Spinner — wraps Bubble Tea's spinner with simple Start/Stop API
  • RenderMessage() — styled message rendering by severity
  • ErrorDisplay — rich error panel with actions

Backward compatible: EmitLog() and EmitWarning() still work (marked deprecated, forward to MessageEvent).

Tests

  • Unit tests for all new event formatting
  • Component tests for Spinner and ErrorDisplay
  • App integration tests for event handling
  • Updated existing tests to use new event types
go test ./internal/output/... ./internal/ui/...

Follow-ups

  • Use EmitSpinnerStart/EmitSpinnerStop in start command during image pull (or introduce a loading bar -> we'll see what's better)
  • Use EmitError for Docker connection failures and auth errors

Related

@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

Replaces LogEvent/WarningEvent with MessageEvent (with severities), adds SpinnerEvent and ErrorEvent, updates formatters, sinks, tests, and auth/container emitters, and introduces Spinner and ErrorDisplay UI components and related styles; also bumps charmbracelet deps in go.mod.

Changes

Cohort / File(s) Summary
Dependencies
go.mod
Added/updated charmbracelet dependencies (github.com/charmbracelet/bubbles indirect) and bumped github.com/charmbracelet/x/exp/golden.
Event system core
internal/output/events.go
Replaced LogEvent/WarningEvent with MessageEvent (severity + text); added MessageSeverity, SpinnerEvent, ErrorEvent, ErrorAction, and new emitters (EmitInfo, EmitSuccess, EmitNote, EmitSpinnerStart, EmitSpinnerStop, EmitError); removed legacy event types.
Formatting & tests
internal/output/format.go, internal/output/format_test.go
Formatter now handles MessageEvent, SpinnerEvent, ErrorEvent; added formatMessageEvent/formatErrorEvent; tests expanded for severities, spinner states, and error formatting.
Sinks & sink tests
internal/output/plain_sink_test.go, internal/output/tui_sink_test.go
Replaced Log/Warning usages with MessageEvent; added spinner/error test cases; updated expected outputs and parity tests.
Auth & login
internal/auth/auth.go, internal/auth/auth_test.go, internal/auth/login.go
Switched emitted logs to new emitters (EmitInfo, EmitSuccess, EmitWarning) and added an early return on login error; tests updated to expect MessageEvent.
Container start
internal/container/start.go
Changed a single EmitLog call to EmitInfo when a container is already running.
UI application
internal/ui/app.go, internal/ui/app_test.go
App now holds spinner and errorDisplay fields; Init uses spinner.Tick(); Update handles spinner/message/error events; View renders spinner and error display; tests added for spinner/error/message interactions.
UI components
internal/ui/components/spinner.go, internal/ui/components/spinner_test.go, internal/ui/components/error_display.go, internal/ui/components/error_display_test.go, internal/ui/components/message.go
Added Spinner component (wraps bubbles spinner) and tests; added ErrorDisplay component and tests; added RenderMessage helper for severity-based rendering.
UI styles
internal/ui/styles/styles.go
Added style variables: Success, Note, Warning, Secondary, ErrorTitle, ErrorDetail, ErrorAction, and SpinnerStyle.

Sequence Diagram

sequenceDiagram
    participant Emitter as Event Emitter
    participant Sink as Event Sink
    participant Formatter as Formatter
    participant UIApp as UI App
    participant Component as UI Component

    Emitter->>Sink: EmitInfo/EmitSuccess/EmitNote/EmitWarning/EmitError/EmitSpinnerStart/Stop
    Sink->>Formatter: deliver Event (MessageEvent/SpinnerEvent/ErrorEvent)
    Formatter->>Formatter: formatMessageEvent() / formatErrorEvent()
    Formatter->>UIApp: forward formatted event
    UIApp->>UIApp: Update() processes event
    alt SpinnerEvent
        UIApp->>Component: spinner.Start(text) / spinner.Stop()
    else ErrorEvent
        UIApp->>Component: errorDisplay.Show(event)
        UIApp->>Component: spinner.Stop()
    else MessageEvent
        UIApp->>UIApp: append formatted line to history
    end
    UIApp->>Component: View()
    Component->>UIApp: rendered string
    UIApp->>UIApp: compose final output
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • carole-lavillonniere
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main change: introducing a unified event system and adding reusable TUI components, which is precisely what the PR accomplishes.
Description check ✅ Passed The description comprehensively relates to the changeset, detailing motivation, new event types, new components, backward compatibility, tests, and follow-ups that all match the PR changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch silv-io/flc-369

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
internal/ui/styles/styles.go (1)

31-57: Consider removing self-explanatory section comments.

The grouping comments here restate what the code already makes obvious; keeping just semantic variable names is cleaner.

As per coding guidelines **/*.go: Don't add comments for self-explanatory code. Only comment when the "why" isn't obvious from the code itself.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/ui/styles/styles.go` around lines 31 - 57, Remove the
self-explanatory grouping comments and leave the semantic style variables as-is;
specifically delete the comment lines "Message severity styles",
"Secondary/muted style for prefixes", "Error styles" and "Spinner style" while
keeping the style declarations (Success, Note, Warning, Secondary, ErrorTitle,
ErrorDetail, ErrorAction, SpinnerStyle) and their formatting intact so only
non-actionable, redundant comments are removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@internal/output/events.go`:
- Around line 126-128: The deprecation comment on EmitWarning is
self-referential; update the doc comment to point users to the non-deprecated
alternative by instructing them to call Emit with a MessageEvent (e.g., use
Emit(sink, MessageEvent{Severity: SeverityWarning, Text: ...})); modify the
comment above func EmitWarning to mention Emit and MessageEvent/SeverityWarning
explicitly instead of referencing EmitWarning itself.

In `@internal/ui/components/message.go`:
- Around line 8-19: RenderMessage currently duplicates severity label/prefix
logic; replace that logic by calling the shared formatter
output.FormatEventLine(e) to get the canonical line semantics and then apply TUI
styling. Concretely, remove the switch in RenderMessage, call formatted :=
output.FormatEventLine(e), and return the TUI prefix (styles.Secondary.Render(">
")) plus the styled formatted string (e.g. styles.Message.Render(formatted)) so
the same semantics are produced by FormatEventLine while keeping TUI-specific
styling in RenderMessage.

In `@internal/ui/components/spinner_test.go`:
- Around line 51-54: The test assigns the updated spinner to variable s from
spinner.Update but never uses it, causing SA4006; change the assignment to use
the blank identifier so the linter is satisfied—replace the left-hand variable
`s` with `_` in the call to `spinner.Update` (e.g., use `_, cmd :=
spinner.Update(spinner.TickMsg{})`) so `cmd` is still captured and `s` is not
unused.

---

Nitpick comments:
In `@internal/ui/styles/styles.go`:
- Around line 31-57: Remove the self-explanatory grouping comments and leave the
semantic style variables as-is; specifically delete the comment lines "Message
severity styles", "Secondary/muted style for prefixes", "Error styles" and
"Spinner style" while keeping the style declarations (Success, Note, Warning,
Secondary, ErrorTitle, ErrorDetail, ErrorAction, SpinnerStyle) and their
formatting intact so only non-actionable, redundant comments are removed.

ℹ️ Review info

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3200ba4 and 53aa465.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (15)
  • go.mod
  • internal/auth/auth_test.go
  • internal/output/events.go
  • internal/output/format.go
  • internal/output/format_test.go
  • internal/output/plain_sink_test.go
  • internal/output/tui_sink_test.go
  • internal/ui/app.go
  • internal/ui/app_test.go
  • internal/ui/components/error_display.go
  • internal/ui/components/error_display_test.go
  • internal/ui/components/message.go
  • internal/ui/components/spinner.go
  • internal/ui/components/spinner_test.go
  • internal/ui/styles/styles.go

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
internal/container/start.go (1)

216-223: Consider migrating the remaining EmitWarning calls in this file.

Lines 216 and 222 still use the deprecated EmitWarning. While these are backward-compatible (they forward to MessageEvent internally), migrating them now keeps this file consistent with the changes introduced in this PR and reduces future churn.

♻️ Proposed migration of EmitWarning calls
-			output.EmitWarning(sink, fmt.Sprintf("failed to close response body: %v", err))
+			output.EmitWarning(sink, fmt.Sprintf("failed to close response body: %v", err)) // or EmitWarning equivalent via MessageEvent if an EmitWarning helper exists

Verify what the new severity-tagged equivalent of EmitWarning is (e.g., output.EmitWarning already forwarding, or a new EmitWarning/EmitNote), then apply consistently to both occurrences.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/container/start.go` around lines 216 - 223, Replace the deprecated
output.EmitWarning(sink, ...) calls with the new severity-tagged message API
used elsewhere in the PR: call output.MessageEvent(sink, output.SeverityWarning,
fmt.Sprintf("failed to close response body: %v", err)) (or the project's
equivalent helper that accepts a severity and message) for both occurrences
around resp.Body.Close and the early-return block; keep the same fmt.Sprintf
text and the same sink and error variable names so behavior and context remain
unchanged.
internal/auth/login.go (1)

87-103: Consider SpinnerEvent for the three transient progress steps in completeAuth.

Lines 87, 95, and 102 are intermediate loading states ("Checking…", "exchanging…", "Fetching…") that are immediately superseded by the next step or by an error. They're semantically SpinnerStart/Stop pairs rather than persistent Info messages. The PR already notes this migration as a follow-up ("Use EmitSpinnerStart/EmitSpinnerStop in relevant commands"), so tagging for awareness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/auth/login.go` around lines 87 - 103, Replace the three transient
output.EmitInfo calls in completeAuth (the calls emitting "Checking if auth
request is confirmed...", "Auth request confirmed, exchanging for token...", and
"Fetching license token...") with spinner events: call
output.EmitSpinnerStart(l.sink, "<message>") before each async operation
(CheckAuthRequestConfirmed, ExchangeAuthRequest, GetLicenseToken) and ensure
matching output.EmitSpinnerStop(l.sink) on both success paths and in error
returns so spinners are stopped when an error occurs; keep the same messages and
leave non-transient final messages as EmitInfo.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/auth/login.go`:
- Around line 87-103: Replace the three transient output.EmitInfo calls in
completeAuth (the calls emitting "Checking if auth request is confirmed...",
"Auth request confirmed, exchanging for token...", and "Fetching license
token...") with spinner events: call output.EmitSpinnerStart(l.sink,
"<message>") before each async operation (CheckAuthRequestConfirmed,
ExchangeAuthRequest, GetLicenseToken) and ensure matching
output.EmitSpinnerStop(l.sink) on both success paths and in error returns so
spinners are stopped when an error occurs; keep the same messages and leave
non-transient final messages as EmitInfo.

In `@internal/container/start.go`:
- Around line 216-223: Replace the deprecated output.EmitWarning(sink, ...)
calls with the new severity-tagged message API used elsewhere in the PR: call
output.MessageEvent(sink, output.SeverityWarning, fmt.Sprintf("failed to close
response body: %v", err)) (or the project's equivalent helper that accepts a
severity and message) for both occurrences around resp.Body.Close and the
early-return block; keep the same fmt.Sprintf text and the same sink and error
variable names so behavior and context remain unchanged.

ℹ️ Review info

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 53aa465 and 5836aba.

📒 Files selected for processing (5)
  • internal/auth/auth.go
  • internal/auth/login.go
  • internal/container/start.go
  • internal/output/events.go
  • internal/ui/components/spinner_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/ui/components/spinner_test.go
  • internal/output/events.go

@silv-io silv-io marked this pull request as draft February 25, 2026 16:48
@silv-io silv-io marked this pull request as ready for review February 27, 2026 12:20
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/output/plain_sink_test.go (1)

172-184: Consider adding Detail assertion in this plain-sink error test.

This case verifies title/summary/actions, but not ErrorEvent.Detail. Adding one Detail case here would strengthen sink-level parity for multiline error rendering.

Based on learnings, "When adding a new event type, update all of: internal/output/events.go (event type + union + emit helper), internal/output/format.go (line formatting fallback), tests in internal/output/*_test.go for formatter/sink behavior parity".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/output/plain_sink_test.go` around lines 172 - 184, Add an
ErrorEvent.Detail to the TestPlainSink_EmitsErrorEvent case and update the
expected output to include the detail line(s) rendered by the plain sink;
specifically, in the TestPlainSink_EmitsErrorEvent test where Emit(sink,
ErrorEvent{...}) is called (and NewPlainSink, ErrorAction, out.String() are
used), add a Detail field to the ErrorEvent and extend the expected string to
include the plain-sink formatting for Detail so the test asserts title, summary,
detail, and actions parity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/output/plain_sink_test.go`:
- Around line 172-184: Add an ErrorEvent.Detail to the
TestPlainSink_EmitsErrorEvent case and update the expected output to include the
detail line(s) rendered by the plain sink; specifically, in the
TestPlainSink_EmitsErrorEvent test where Emit(sink, ErrorEvent{...}) is called
(and NewPlainSink, ErrorAction, out.String() are used), add a Detail field to
the ErrorEvent and extend the expected string to include the plain-sink
formatting for Detail so the test asserts title, summary, detail, and actions
parity.

ℹ️ Review info

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5836aba and 7b4557c.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (18)
  • go.mod
  • internal/auth/auth.go
  • internal/auth/auth_test.go
  • internal/auth/login.go
  • internal/container/start.go
  • internal/output/events.go
  • internal/output/format.go
  • internal/output/format_test.go
  • internal/output/plain_sink_test.go
  • internal/output/tui_sink_test.go
  • internal/ui/app.go
  • internal/ui/app_test.go
  • internal/ui/components/error_display.go
  • internal/ui/components/error_display_test.go
  • internal/ui/components/message.go
  • internal/ui/components/spinner.go
  • internal/ui/components/spinner_test.go
  • internal/ui/styles/styles.go
🚧 Files skipped from review as they are similar to previous changes (9)
  • internal/output/tui_sink_test.go
  • go.mod
  • internal/auth/auth.go
  • internal/ui/app_test.go
  • internal/ui/components/spinner.go
  • internal/auth/login.go
  • internal/ui/components/error_display_test.go
  • internal/ui/components/spinner_test.go
  • internal/ui/components/error_display.go

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/output/events.go (1)

126-128: Add deprecation notice to EmitWarning.

EmitLog has a deprecation comment directing to EmitInfo, but EmitWarning lacks similar guidance. Consider adding a deprecation notice or clarifying its status for consistency.

+// Deprecated: Use Emit(sink, MessageEvent{Severity: SeverityWarning, Text: message}) for direct emission
 func EmitWarning(sink Sink, message string) {
 	Emit(sink, MessageEvent{Severity: SeverityWarning, Text: message})
 }

Alternatively, if EmitWarning is intended to remain as a convenience helper (not deprecated), that's fine—just ensure the intent is clear.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/output/events.go` around lines 126 - 128, The EmitWarning helper
lacks any deprecation or intent comment; update the declaration for EmitWarning
to either add a deprecation doc comment pointing users to the preferred API
(e.g., "Deprecated: use EmitInfo or Emit with SeverityWarning") or add a clear
comment stating it is an intentional convenience helper that wraps
Emit/MessageEvent with SeverityWarning; reference EmitWarning, Emit,
MessageEvent, SeverityWarning and EmitInfo/EmitLog in the comment so readers
know the recommended alternative or that it is intentionally retained.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@go.mod`:
- Line 29: Move the module github.com/charmbracelet/bubbles from the indirect
list into the main require block of go.mod because it is directly imported (see
imports of github.com/charmbracelet/bubbles in your code). Edit the require
block to include github.com/charmbracelet/bubbles with the current version
(v1.0.0) and remove its indirect entry, then re-run go mod tidy to update the
module graph so imports in internal/ui (where bubbles is used) resolve as a
direct dependency.

---

Nitpick comments:
In `@internal/output/events.go`:
- Around line 126-128: The EmitWarning helper lacks any deprecation or intent
comment; update the declaration for EmitWarning to either add a deprecation doc
comment pointing users to the preferred API (e.g., "Deprecated: use EmitInfo or
Emit with SeverityWarning") or add a clear comment stating it is an intentional
convenience helper that wraps Emit/MessageEvent with SeverityWarning; reference
EmitWarning, Emit, MessageEvent, SeverityWarning and EmitInfo/EmitLog in the
comment so readers know the recommended alternative or that it is intentionally
retained.

ℹ️ Review info

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7b4557c and 1154ca6.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (18)
  • go.mod
  • internal/auth/auth.go
  • internal/auth/auth_test.go
  • internal/auth/login.go
  • internal/container/start.go
  • internal/output/events.go
  • internal/output/format.go
  • internal/output/format_test.go
  • internal/output/plain_sink_test.go
  • internal/output/tui_sink_test.go
  • internal/ui/app.go
  • internal/ui/app_test.go
  • internal/ui/components/error_display.go
  • internal/ui/components/error_display_test.go
  • internal/ui/components/message.go
  • internal/ui/components/spinner.go
  • internal/ui/components/spinner_test.go
  • internal/ui/styles/styles.go
🚧 Files skipped from review as they are similar to previous changes (9)
  • internal/ui/components/error_display_test.go
  • internal/output/tui_sink_test.go
  • internal/auth/login.go
  • internal/ui/styles/styles.go
  • internal/ui/components/error_display.go
  • internal/ui/components/spinner_test.go
  • internal/output/format_test.go
  • internal/container/start.go
  • internal/ui/components/message.go

github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymanbagabas/go-udiff v0.3.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether bubbles is directly imported by project source.

echo "=== Non-test files importing bubbles ==="
rg -n --type=go '"github.com/charmbracelet/bubbles' -g '!**/*_test.go'

echo ""
echo "=== Test files importing bubbles ==="
rg -n --type=go '"github.com/charmbracelet/bubbles' -g '**/*_test.go'

Repository: localstack/lstk

Length of output: 367


🏁 Script executed:

#!/bin/bash
# Check the current go.mod structure for bubbles
echo "=== go.mod direct requires section ==="
head -40 go.mod | cat -n

echo ""
echo "=== Search for bubbles in entire go.mod ==="
rg -n 'charmbracelet/bubbles' go.mod

Repository: localstack/lstk

Length of output: 2097


Move bubbles to direct dependencies in the require block

github.com/charmbracelet/bubbles is directly imported in internal/ui/app.go:7 and internal/ui/components/spinner.go:4, so it should be listed as a direct dependency for clearer ownership and stable go mod tidy behavior. Move it from the indirect section to the main require block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@go.mod` at line 29, Move the module github.com/charmbracelet/bubbles from the
indirect list into the main require block of go.mod because it is directly
imported (see imports of github.com/charmbracelet/bubbles in your code). Edit
the require block to include github.com/charmbracelet/bubbles with the current
version (v1.0.0) and remove its indirect entry, then re-run go mod tidy to
update the module graph so imports in internal/ui (where bubbles is used)
resolve as a direct dependency.

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.

1 participant