A Bubble Tea component for rendering multiple
tea.Modelviews side-by-side in a single TUI.
Tab / Shift+Tab to change focus · Run the demo
| Feature | Description |
|---|---|
| Multiple models | Wrap each tea.Model in MultiBubbleModel; layout order matches the slice order. |
| Message routing | Optionally restrict which message types each model receives via registered types or RegisterAllMessageTypes. |
| Equal-width columns | One row, equal width per model, configurable margin (2% of width, min 1). |
| Keyboard focus | Tab / Shift+Tab change focus; optional per-view styling for focused vs unfocused. |
| Optional title | Title above the views with configurable lipgloss style. |
| Layout behavior | Long lines truncated with "…"; shorter lines padded; each styled line ends with SGR reset so styles do not bleed. |
- Go 1.25+ — see go.mod
- Dependencies — Bubble Tea and lipgloss (pulled in automatically)
go get github.com/a0ngo/multibubbleImport the package (the component lives under the module path):
import "github.com/a0ngo/multibubble"
models := []multibubble.MultiBubbleModel{
multibubble.NewMultiBubbleModel(myModel1),
multibubble.NewMultiBubbleModel(myModel2),
}
m := multibubble.NewMultiBubble(models)Create a multi-bubble with NewMultiBubble and a slice of MultiBubbleModel. Use NewMultiBubbleModel(tea.Model) so each model receives all messages, or NewMultiBubbleModelWithTypes(model, &types) to restrict messages by type. Optionally set a title and size, then run with Bubble Tea. Handle tea.WindowSizeMsg by calling SetSize. Use Tab / Shift+Tab to change focus; call FocusedModelIndex() to read the current focus.
import (
tea "github.com/charmbracelet/bubbletea"
"github.com/a0ngo/multibubble"
)
models := []multibubble.MultiBubbleModel{
multibubble.NewMultiBubbleModel(model1),
multibubble.NewMultiBubbleModel(model2),
}
m := multibubble.NewMultiBubble(models)
m.SetTitle("My App")
m.SetSize(80, 24)
// In your Update, on tea.WindowSizeMsg: m.SetSize(msg.Width, msg.Height)
p := tea.NewProgram(m, tea.WithAltScreen())
p.Run()Runnable demo: examples/simple. From the repo root:
go run ./examples/simpleBy default, NewMultiBubbleModel(model) delivers every message to the model (it uses RegisterAllMessageTypes). Use NewMultiBubbleModelWithTypes(model, &types) so a model only receives messages whose type is in types. The focused model always receives every message regardless of registration (so focus and key handling work as expected). That keeps input handling local (e.g. only the focused input gets key events) and avoids unnecessary updates (e.g. a spinner need not see resize messages).
Example: model receives all messages
// Every tea.Msg (KeyMsg, WindowSizeMsg, etc.) is delivered.
models := []multibubble.MultiBubbleModel{
multibubble.NewMultiBubbleModel(mySpinner),
multibubble.NewMultiBubbleModel(myStatusPanel),
}Example: model receives only key events
import "reflect"
keyTypes := []reflect.Type{
reflect.TypeFor[tea.KeyMsg](),
}
models := []multibubble.MultiBubbleModel{
multibubble.NewMultiBubbleModelWithTypes(myTextInput, &keyTypes),
}
// myTextInput.Update is only called for tea.KeyMsg.Example: model receives keys and a custom focus message
type focusMsg int
msgTypes := []reflect.Type{
reflect.TypeFor[tea.KeyMsg](),
reflect.TypeFor[focusMsg](),
}
models := []multibubble.MultiBubbleModel{
multibubble.NewMultiBubbleModelWithTypes(myInput, &msgTypes),
}
// Send focusMsg from the root when focus changes so the input can call Focus()/Blur().Example: explicitly “receive all” by reference
// Same as NewMultiBubbleModel: all message types are delivered.
models := []multibubble.MultiBubbleModel{
multibubble.NewMultiBubbleModelWithTypes(myModel, multibubble.RegisterAllMessageTypes),
}Mixing models
// Spinner and status get everything; input only gets KeyMsg and focusMsg.
keyAndFocusTypes := []reflect.Type{
reflect.TypeFor[tea.KeyMsg](),
reflect.TypeFor[focusMsg](),
}
models := []multibubble.MultiBubbleModel{
multibubble.NewMultiBubbleModel(spinnerModel),
multibubble.NewMultiBubbleModel(statusModel),
multibubble.NewMultiBubbleModelWithTypes(inputModel, &keyAndFocusTypes),
}
m := multibubble.NewMultiBubble(models)| Symbol | Description |
|---|---|
NewMultiBubble(models []MultiBubbleModel) *MultiBubble |
Constructor; layout order matches the slice. |
SetModels(models []MultiBubbleModel) |
Set or replace the child models. |
FocusedModelIndex() int |
Returns the 0-based index of the currently focused model. |
SetTitle(title string) |
Title above the views; empty means no title line. |
SetTitleStyle(style lipgloss.Style) |
Lipgloss style for the title. |
SetSize(width, height int) |
Required: call when your program receives tea.WindowSizeMsg. |
SetStyleForView(f StyleForViewFunc) |
Optional. Style for unfocused views. func(modelIndex int, view string, width, height int) lipgloss.Style. |
SetStyleForFocusedView(f StyleForFocusedViewFunc) |
Optional. Style for the focused view. func(modelIndex int, view string) lipgloss.Style. |
MultiBubble implements tea.Model (Init, Update, View).
| Symbol | Description |
|---|---|
MultiBubbleModel |
Wraps a tea.Model and optional registered message types. |
NewMultiBubbleModel(model tea.Model) MultiBubbleModel |
Model receives all message types. |
NewMultiBubbleModelWithTypes(model tea.Model, types *[]reflect.Type) MultiBubbleModel |
Model receives only the given message types. Pass RegisterAllMessageTypes for all; pass nil for none. |
RegisterAllMessageTypes |
Sentinel *[]reflect.Type; when used as the types argument, the model receives all messages. |
IsRegisteredMsgType(msg tea.Msg) bool |
Reports whether the message type is registered (all if RegisterAllMessageTypes; none if nil; otherwise per-slice). |
Views are laid out in a single row. Each model gets equal width; a margin (2% of total width, min 1) is placed between columns. If a title is set, it is rendered on the first line. Per-column: lines longer than the column width are truncated with "…"; shorter lines are space-padded. Each styled line is closed with an SGR reset so styles do not bleed into padding or adjacent columns.
go test ./...See examples/simple for a runnable program: spinner (bubbles), focus-aware text panel, and interactive text input; Tab/Shift+Tab to change focus, Ctrl+C to quit, and optional focused-view styling.
Apache 2.0. See LICENSE.
