Make TUI startup render immediately

The TUI felt hung because banger tui blocked on ensureDaemon before Bubble Tea started, then treated the initial vm and image fetch as one combined loading gate.

Start the program first, move daemon bootstrap into staged TUI commands, render the full layout with inline loading placeholders, and split vm list and image list startup so VMs can appear before images finish loading. Also record per-stage timings so the slow step is visible in the status line instead of hidden behind a generic loading state.

Verified with go test ./internal/cli, go test ./..., and make build.
This commit is contained in:
Thales Maciel 2026-03-18 14:37:17 -03:00
parent 8ba920eda6
commit 3a92362829
No known key found for this signature in database
GPG key ID: 33112E6833C34679
2 changed files with 467 additions and 54 deletions

View file

@ -31,6 +31,17 @@ import (
const tuiRefreshInterval = 3 * time.Second
var (
tuiEnsureDaemonFunc = ensureDaemon
tuiProgramRunner = func(model tuiModel) error {
program := tea.NewProgram(model, tea.WithAltScreen())
return program.Start()
}
tuiIsTerminal = func(fd uintptr) bool {
return isatty.IsTerminal(fd)
}
)
type tuiMode int
const (
@ -52,11 +63,27 @@ const (
actionLogs actionKind = "logs"
)
type dataLoadedMsg struct {
vms []model.VMRecord
images []model.Image
focusID string
err error
type daemonReadyMsg struct {
generation int
layout paths.Layout
cfg model.DaemonConfig
duration time.Duration
err error
}
type vmListLoadedMsg struct {
generation int
vms []model.VMRecord
focusID string
duration time.Duration
err error
}
type imageListLoadedMsg struct {
generation int
images []model.Image
duration time.Duration
err error
}
type statsLoadedMsg struct {
@ -447,9 +474,18 @@ type tuiModel struct {
height int
ready bool
loading bool
busy string
sudoValidated bool
loadGeneration int
loading bool
busy string
sudoValidated bool
daemonReady bool
daemonPending bool
vmListPending bool
imagePending bool
imagesLoaded bool
daemonLoadDur time.Duration
vmListDur time.Duration
imageListDur time.Duration
mode tuiMode
form *vmForm
@ -507,19 +543,26 @@ func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel {
helpView := help.New()
return tuiModel{
layout: layout,
cfg: cfg,
table: vmTable,
detail: detail,
help: helpView,
spinner: spin,
browseKeys: newBrowseKeyMap(),
formKeys: newFormKeyMap(),
confirmKeys: newConfirmKeyMap(),
loading: true,
statusText: "Loading VMs...",
model := tuiModel{
layout: layout,
cfg: cfg,
width: 120,
height: 32,
ready: true,
table: vmTable,
detail: detail,
help: helpView,
spinner: spin,
browseKeys: newBrowseKeyMap(),
formKeys: newFormKeyMap(),
confirmKeys: newConfirmKeyMap(),
loadGeneration: 1,
loading: true,
daemonPending: true,
statusText: "Starting daemon...",
}
model.resize()
return model
}
func newTUICommand() *cobra.Command {
@ -528,21 +571,16 @@ func newTUICommand() *cobra.Command {
Short: "Launch a terminal UI to manage VMs",
Args: noArgsUsage("usage: banger tui"),
RunE: func(cmd *cobra.Command, args []string) error {
if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stdout.Fd()) {
if !tuiIsTerminal(os.Stdin.Fd()) || !tuiIsTerminal(os.Stdout.Fd()) {
return errors.New("tui requires an interactive terminal")
}
layout, cfg, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
program := tea.NewProgram(newTUIModel(layout, cfg), tea.WithAltScreen())
return program.Start()
return tuiProgramRunner(newTUIModel(paths.Layout{}, model.DaemonConfig{}))
},
}
}
func (m tuiModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, refreshTickCmd(), fetchDataCmd(m.layout, ""))
return tea.Batch(m.spinner.Tick, refreshTickCmd(), ensureDaemonCmd(m.loadGeneration))
}
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -560,14 +598,34 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
m.resize()
case dataLoadedMsg:
m.loading = false
if msg.err != nil {
m.setStatus(msg.err.Error(), true)
case daemonReadyMsg:
if msg.generation != m.loadGeneration {
break
}
m.daemonPending = false
if msg.err != nil {
m.syncLoadingState()
m.setStatus(fmt.Sprintf("starting daemon: %v", msg.err), true)
break
}
m.layout = msg.layout
m.cfg = msg.cfg
m.daemonReady = true
m.daemonLoadDur = msg.duration
m.beginListLoad("")
cmds = append(cmds, m.spinner.Tick, fetchVMListCmd(m.layout, "", m.loadGeneration), fetchImageListCmd(m.layout, m.loadGeneration))
case vmListLoadedMsg:
if msg.generation != m.loadGeneration {
break
}
m.vmListPending = false
if msg.err != nil {
m.syncLoadingState()
m.setStatus(fmt.Sprintf("loading vms: %v", msg.err), true)
break
}
m.images = msg.images
m.vms = msg.vms
m.vmListDur = msg.duration
targetID := m.selectedID
if msg.focusID != "" {
targetID = msg.focusID
@ -575,7 +633,8 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.selectedID = resolveSelectedID(targetID, msg.vms)
m.lastRefresh = time.Now()
m.rebuildTable()
m.setStatus(fmt.Sprintf("Loaded %d VM(s)", len(msg.vms)), false)
m.syncLoadingState()
m.setLoadStatus(false)
if m.selectedID != "" {
cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID))
} else {
@ -583,6 +642,21 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statsID = ""
m.statsErr = ""
}
case imageListLoadedMsg:
if msg.generation != m.loadGeneration {
break
}
m.imagePending = false
if msg.err != nil {
m.syncLoadingState()
m.setStatus(fmt.Sprintf("loading images: %v", msg.err), true)
break
}
m.images = msg.images
m.imagesLoaded = true
m.imageListDur = msg.duration
m.syncLoadingState()
m.setLoadStatus(false)
case statsLoadedMsg:
if msg.id != m.selectedID {
break
@ -613,8 +687,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.setStatus(msg.status, false)
if msg.refresh {
m.loading = true
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, msg.focusID))
cmds = append(cmds, m.beginRefreshLoad(msg.focusID)...)
}
case externalPreparedMsg:
if msg.err != nil {
@ -646,9 +719,8 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case refreshTickMsg:
cmds = append(cmds, refreshTickCmd())
if m.busy == "" && m.mode == tuiModeBrowse {
m.loading = true
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID))
if m.busy == "" && m.mode == tuiModeBrowse && m.daemonReady && !m.vmListPending && !m.imagePending {
cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...)
}
case tea.KeyMsg:
switch m.mode {
@ -679,9 +751,16 @@ func (m tuiModel) updateBrowse(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
case key.Matches(msg, m.browseKeys.help):
m.help.ShowAll = !m.help.ShowAll
case key.Matches(msg, m.browseKeys.refresh):
m.loading = true
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID))
if !m.daemonReady {
cmds = append(cmds, m.beginBootstrapLoad()...)
} else {
cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...)
}
case key.Matches(msg, m.browseKeys.create):
if !m.imagesLoaded {
m.setStatus("Images are still loading", true)
return m, cmds
}
if len(m.images) == 0 {
m.setStatus("Create requires at least one image", true)
return m, cmds
@ -827,9 +906,6 @@ func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
}
func (m tuiModel) View() string {
if !m.ready {
return "Loading..."
}
header := m.renderHeader()
body := m.renderBody()
status := m.renderStatus()
@ -876,16 +952,20 @@ func (m tuiModel) bodyHeight() int {
func (m tuiModel) renderHeader() string {
status := "idle"
if m.loading {
status = m.spinner.View() + " loading"
} else if m.busy != "" {
if m.busy != "" {
status = m.spinner.View() + " " + m.busy
} else if phase := m.loadingPhase(); phase != "" {
status = m.spinner.View() + " " + phase
}
refreshed := "never"
if !m.lastRefresh.IsZero() {
refreshed = relativeTime(m.lastRefresh)
}
header := fmt.Sprintf("banger tui socket %s %s last refresh %s", filepath.Base(m.layout.SocketPath), status, refreshed)
socketLabel := filepath.Base(m.layout.SocketPath)
if socketLabel == "." || socketLabel == "" {
socketLabel = "pending"
}
header := fmt.Sprintf("banger tui socket %s %s last refresh %s", socketLabel, status, refreshed)
return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header)
}
@ -909,7 +989,7 @@ func (m tuiModel) renderBody() string {
leftContent := m.table.View()
if len(m.vms) == 0 {
leftContent = "No VMs.\n\nPress c to create one."
leftContent = m.vmListPlaceholder()
}
leftPanel := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
@ -979,7 +1059,7 @@ func (m *tuiModel) rebuildTable() {
func (m *tuiModel) refreshDetail() {
vm, ok := m.selectedVM()
if !ok {
m.detail.SetContent("No VM selected.\n\nUse c to create a VM.")
m.detail.SetContent(m.detailPlaceholder())
return
}
stats := vm.Stats
@ -1031,6 +1111,158 @@ func (m *tuiModel) setStatus(text string, isErr bool) {
m.statusErr = isErr
}
func (m *tuiModel) beginBootstrapLoad() []tea.Cmd {
m.loadGeneration++
m.daemonReady = false
m.daemonPending = true
m.vmListPending = false
m.imagePending = false
m.imagesLoaded = false
m.images = nil
m.daemonLoadDur = 0
m.vmListDur = 0
m.imageListDur = 0
m.syncLoadingState()
m.setLoadStatus(false)
return []tea.Cmd{m.spinner.Tick, ensureDaemonCmd(m.loadGeneration)}
}
func (m *tuiModel) beginListLoad(focusID string) {
m.vmListPending = true
m.imagePending = true
if len(m.images) == 0 {
m.imagesLoaded = false
}
m.vmListDur = 0
m.imageListDur = 0
m.syncLoadingState()
m.setLoadStatus(false)
if focusID == "" {
return
}
m.selectedID = focusID
}
func (m *tuiModel) beginRefreshLoad(focusID string) []tea.Cmd {
if !m.daemonReady {
return m.beginBootstrapLoad()
}
m.loadGeneration++
m.beginListLoad(focusID)
return []tea.Cmd{
m.spinner.Tick,
fetchVMListCmd(m.layout, focusID, m.loadGeneration),
fetchImageListCmd(m.layout, m.loadGeneration),
}
}
func (m *tuiModel) syncLoadingState() {
m.loading = m.daemonPending || m.vmListPending || m.imagePending
}
func (m tuiModel) loadingPhase() string {
switch {
case m.daemonPending:
return "starting daemon"
case m.vmListPending && m.imagePending:
return "loading vms and images"
case m.vmListPending:
return "loading vms"
case m.imagePending:
return "loading images"
default:
return ""
}
}
func (m *tuiModel) setLoadStatus(isErr bool) {
if phase := m.loadingPhase(); phase != "" {
durations := m.stageDurations()
switch phase {
case "loading images":
prefix := fmt.Sprintf("Loaded %d VM(s); loading images", len(m.vms))
if durations != "" {
m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr)
return
}
m.setStatus(prefix+"...", isErr)
return
case "loading vms":
prefix := fmt.Sprintf("Loaded %d image(s); loading vms", len(m.images))
if durations != "" {
m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr)
return
}
m.setStatus(prefix+"...", isErr)
return
}
if durations != "" {
m.setStatus(fmt.Sprintf("%s (%s)", capitalizePhase(phase), durations), isErr)
return
}
m.setStatus(capitalizePhase(phase)+"...", isErr)
return
}
if m.daemonReady && m.vmListDur > 0 && m.imageListDur > 0 {
m.setStatus(fmt.Sprintf("Loaded %d VM(s) (%s)", len(m.vms), m.stageDurations()), isErr)
}
}
func (m tuiModel) stageDurations() string {
parts := make([]string, 0, 3)
if m.daemonLoadDur > 0 {
parts = append(parts, "daemon "+formatTUIDuration(m.daemonLoadDur))
}
if m.vmListDur > 0 {
parts = append(parts, "vm list "+formatTUIDuration(m.vmListDur))
}
if m.imageListDur > 0 {
parts = append(parts, "image list "+formatTUIDuration(m.imageListDur))
}
return strings.Join(parts, ", ")
}
func (m tuiModel) vmListPlaceholder() string {
switch {
case m.daemonPending:
return "Starting daemon...\n\nWaiting for bangerd to become ready."
case m.vmListPending:
return "Loading VMs..."
default:
return "No VMs.\n\nPress c to create one."
}
}
func (m tuiModel) detailPlaceholder() string {
switch {
case m.daemonPending:
return "Starting daemon...\n\nThe TUI will populate once bangerd is ready."
case m.vmListPending:
return "Loading VMs..."
case len(m.vms) == 0:
if m.imagePending {
return "No VM selected.\n\nImages are still loading."
}
return "No VM selected.\n\nUse c to create a VM."
default:
return "No VM selected."
}
}
func ensureDaemonCmd(generation int) tea.Cmd {
return func() tea.Msg {
start := time.Now()
layout, cfg, err := tuiEnsureDaemonFunc(context.Background())
return daemonReadyMsg{
generation: generation,
layout: layout,
cfg: cfg,
duration: time.Since(start),
err: err,
}
}
}
func (m tuiModel) selectedVM() (model.VMRecord, bool) {
for _, vm := range m.vms {
if vm.ID == m.selectedID {
@ -1193,17 +1425,34 @@ func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd {
}
}
func fetchDataCmd(layout paths.Layout, focusID string) tea.Cmd {
func fetchVMListCmd(layout paths.Layout, focusID string, generation int) tea.Cmd {
return func() tea.Msg {
start := time.Now()
vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{})
if err != nil {
return dataLoadedMsg{err: err, focusID: focusID}
return vmListLoadedMsg{generation: generation, err: err, focusID: focusID}
}
return vmListLoadedMsg{
generation: generation,
vms: vms.VMs,
focusID: focusID,
duration: time.Since(start),
}
}
}
func fetchImageListCmd(layout paths.Layout, generation int) tea.Cmd {
return func() tea.Msg {
start := time.Now()
images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{})
if err != nil {
return dataLoadedMsg{vms: vms.VMs, err: err, focusID: focusID}
return imageListLoadedMsg{generation: generation, err: err}
}
return imageListLoadedMsg{
generation: generation,
images: images.Images,
duration: time.Since(start),
}
return dataLoadedMsg{vms: vms.VMs, images: images.Images, focusID: focusID}
}
}
@ -1237,6 +1486,24 @@ func resolveSelectedID(targetID string, vms []model.VMRecord) string {
return vms[0].ID
}
func capitalizePhase(value string) string {
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
}
func formatTUIDuration(value time.Duration) string {
switch {
case value >= time.Second:
return value.Round(100 * time.Millisecond).String()
case value >= 100*time.Millisecond:
return value.Round(10 * time.Millisecond).String()
default:
return value.Round(time.Millisecond).String()
}
}
func imageNames(images []model.Image) []string {
names := make([]string, 0, len(images))
for _, image := range images {

View file

@ -1,9 +1,15 @@
package cli
import (
"context"
"strings"
"testing"
"time"
"banger/internal/model"
"banger/internal/paths"
tea "github.com/charmbracelet/bubbletea"
)
func TestCreateVMFormSubmit(t *testing.T) {
@ -87,3 +93,143 @@ func TestResolveSelectedID(t *testing.T) {
t.Fatalf("resolveSelectedID empty = %q, want empty", got)
}
}
func TestNewTUICommandStartsProgramWithoutEnsuringDaemon(t *testing.T) {
origEnsure := tuiEnsureDaemonFunc
origRunner := tuiProgramRunner
origTerminal := tuiIsTerminal
t.Cleanup(func() {
tuiEnsureDaemonFunc = origEnsure
tuiProgramRunner = origRunner
tuiIsTerminal = origTerminal
})
ensureCalled := false
tuiEnsureDaemonFunc = func(ctx context.Context) (paths.Layout, model.DaemonConfig, error) {
ensureCalled = true
return paths.Layout{}, model.DaemonConfig{}, nil
}
tuiProgramRunner = func(model tuiModel) error {
if ensureCalled {
t.Fatal("ensureDaemon should not run before the TUI starts")
}
if !model.daemonPending || !model.loading {
t.Fatalf("startup model = %+v, want pending daemon startup", model)
}
return nil
}
tuiIsTerminal = func(fd uintptr) bool { return true }
cmd := NewBangerCommand()
cmd.SetArgs([]string{"tui"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if ensureCalled {
t.Fatal("ensureDaemon should not have been called")
}
}
func TestTUIViewRendersLayoutImmediately(t *testing.T) {
m := newTUIModel(paths.Layout{}, model.DaemonConfig{})
view := m.View()
if strings.Contains(view, "Loading...") {
t.Fatalf("view = %q, want full layout instead of one-line loading", view)
}
if !strings.Contains(view, "Starting daemon") {
t.Fatalf("view = %q, want startup placeholder", view)
}
}
func TestTUIVMLoadCanCompleteBeforeImages(t *testing.T) {
now := time.Date(2026, time.March, 18, 12, 0, 0, 0, time.UTC)
initial := newTUIModel(paths.Layout{}, model.DaemonConfig{})
updated, _ := initial.Update(daemonReadyMsg{
generation: initial.loadGeneration,
layout: paths.Layout{SocketPath: "/tmp/bangerd.sock"},
cfg: model.DaemonConfig{DefaultImageName: "default"},
duration: 2400 * time.Millisecond,
})
m := updated.(tuiModel)
if !m.daemonReady || !m.vmListPending || !m.imagePending {
t.Fatalf("model after daemonReady = %+v, want pending vm/image loads", m)
}
vm := model.VMRecord{
ID: "vm-1",
Name: "devbox",
State: model.VMStateRunning,
CreatedAt: now,
UpdatedAt: now,
LastTouchedAt: now,
Spec: model.VMSpec{
VCPUCount: 2,
MemoryMiB: 1024,
WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024,
},
Runtime: model.VMRuntime{
GuestIP: "172.16.0.2",
DNSName: "devbox.vm",
},
}
updated, _ = m.Update(vmListLoadedMsg{
generation: m.loadGeneration,
vms: []model.VMRecord{vm},
duration: 20 * time.Millisecond,
})
m = updated.(tuiModel)
if len(m.vms) != 1 || m.selectedID != vm.ID {
t.Fatalf("model after vmListLoaded = %+v, want selected vm", m)
}
if !m.imagePending {
t.Fatalf("image load should still be pending: %+v", m)
}
if strings.Contains(m.View(), "No VMs") {
t.Fatalf("view should render the loaded VM while images are pending: %q", m.View())
}
if !strings.Contains(m.View(), "devbox") {
t.Fatalf("view = %q, want loaded VM name", m.View())
}
}
func TestTUICreateBlockedWhileImagesLoad(t *testing.T) {
m := newTUIModel(paths.Layout{}, model.DaemonConfig{})
m.daemonPending = false
m.daemonReady = true
m.imagePending = true
m.loading = true
updated, _ := m.updateBrowse(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
if updated.mode != tuiModeBrowse {
t.Fatalf("mode = %v, want browse", updated.mode)
}
if updated.statusText != "Images are still loading" {
t.Fatalf("status = %q, want image loading warning", updated.statusText)
}
}
func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) {
initial := newTUIModel(paths.Layout{}, model.DaemonConfig{})
updated, _ := initial.Update(daemonReadyMsg{
generation: initial.loadGeneration,
layout: paths.Layout{SocketPath: "/tmp/bangerd.sock"},
duration: 2400 * time.Millisecond,
})
m := updated.(tuiModel)
updated, _ = m.Update(vmListLoadedMsg{
generation: m.loadGeneration,
vms: []model.VMRecord{},
duration: 20 * time.Millisecond,
})
m = updated.(tuiModel)
updated, _ = m.Update(imageListLoadedMsg{
generation: m.loadGeneration,
images: []model.Image{{Name: "default"}},
duration: 15 * time.Millisecond,
})
m = updated.(tuiModel)
if !strings.Contains(m.statusText, "daemon 2.4s") || !strings.Contains(m.statusText, "vm list 20ms") || !strings.Contains(m.statusText, "image list 15ms") {
t.Fatalf("statusText = %q, want stage timings", m.statusText)
}
}