go-blockchain/tui/status_model.go
Claude df6d4c82c0
feat(tui): add StatusModel for chain sync header
Implements StatusModel as a cli.FrameModel that renders a single-line
chain status bar showing height, sync percentage, difficulty, peer
count and tip age. Includes formatAge and formatDifficulty helpers
with SI suffixes. Adds core/cli as a direct dependency.

Co-Authored-By: Charon <charon@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:55:22 +00:00

95 lines
2.5 KiB
Go

// Copyright (c) 2017-2026 Lethean (https://lt.hn)
//
// Licensed under the European Union Public Licence (EUPL) version 1.2.
// SPDX-License-Identifier: EUPL-1.2
package tui
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
cli "forge.lthn.ai/core/cli/pkg/cli"
)
// Compile-time check: StatusModel implements cli.FrameModel.
var _ cli.FrameModel = (*StatusModel)(nil)
// StatusModel displays chain sync status in the header region.
type StatusModel struct {
node *Node
status NodeStatusMsg
}
// NewStatusModel creates a StatusModel backed by the given Node.
func NewStatusModel(n *Node) *StatusModel {
return &StatusModel{node: n}
}
// Init returns a command that reads the current chain status immediately.
func (m *StatusModel) Init() tea.Cmd {
return m.node.WaitForStatus()
}
// Update handles incoming messages. On NodeStatusMsg it stores the snapshot
// and schedules the next tick; all other messages are ignored.
func (m *StatusModel) Update(msg tea.Msg) (cli.FrameModel, tea.Cmd) {
switch msg := msg.(type) {
case NodeStatusMsg:
m.status = msg
return m, m.node.Tick()
default:
return m, nil
}
}
// View renders a single-line status bar. When height is zero the model has
// not yet received a status snapshot, so it shows a placeholder.
func (m *StatusModel) View(width, height int) string {
var line string
if height == 0 {
line = " height 0 | syncing..."
} else {
s := m.status
line = fmt.Sprintf(" height %d | sync %.1f%% | diff %s | %d peers | tip %s",
s.Height, s.SyncPct, formatDifficulty(s.Difficulty), s.PeerCount, formatAge(s.TipTime))
}
if len(line) > width && width > 0 {
line = line[:width]
}
return line
}
// formatAge returns a human-readable duration since t.
func formatAge(t time.Time) string {
if t.IsZero() {
return "\u2014"
}
d := time.Since(t)
switch {
case d < time.Minute:
return fmt.Sprintf("%ds ago", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
// formatDifficulty returns a compact difficulty string with SI suffix.
func formatDifficulty(d uint64) string {
switch {
case d >= 1_000_000_000:
return fmt.Sprintf("%.1fG", float64(d)/1_000_000_000)
case d >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(d)/1_000_000)
case d >= 1_000:
return fmt.Sprintf("%.1fK", float64(d)/1_000)
default:
return fmt.Sprintf("%d", d)
}
}