feat: add miner event log tab

This commit is contained in:
2026-04-29 23:38:38 +02:00
parent 61e140fb03
commit f131f0bc71
9 changed files with 371 additions and 36 deletions
+7
View File
@@ -27,6 +27,7 @@ type StreamerUpdate struct {
Viewer *twitch.Viewer
Entry *twitch.StreamerEntry
IRC *IRCUpdate
Miner *MinerUpdate
Index int
Err error
Done bool
@@ -39,6 +40,12 @@ type IRCUpdate struct {
Line string
}
// MinerUpdate carries one miner log line into the TUI.
type MinerUpdate struct {
Login string
Line string
}
type authStartedMsg struct {
Updates <-chan AuthUpdate
}
+89 -17
View File
@@ -26,7 +26,8 @@ type panelFocus int
const (
focusStreamers panelFocus = iota
focusInfo
focusChat
focusIRC
focusMiner
)
type detailTab int
@@ -34,6 +35,7 @@ type detailTab int
const (
infoTab detailTab = iota
ircTab
minerTab
)
type modelRuntime interface {
@@ -58,8 +60,10 @@ type Model struct {
selectedConfig string
focus panelFocus
ircDetails map[string]ircDetail
minerDetails map[string][]string
authViewport viewport.Model
ircViewport viewport.Model
minerViewport viewport.Model
}
type ircDetail struct {
@@ -81,18 +85,23 @@ func New(options Options) Model {
authLogs: []string{},
mode: authView,
ircDetails: make(map[string]ircDetail),
minerDetails: make(map[string][]string),
width: defaultViewWidth,
height: 24,
focus: focusStreamers,
authViewport: newAuthViewport(contentWidth(defaultViewWidth), authViewportHeight(24)),
ircViewport: newIRCViewport(detailViewportWidth(defaultViewWidth), detailViewportHeight(24)),
minerViewport: newMinerViewport(
detailViewportWidth(defaultViewWidth),
detailViewportHeight(24),
),
}
if options.AuthState != nil {
model.mode = streamerView
}
model.ensureSelection()
model.syncAuthViewport()
model.syncIRCViewport(true)
model.syncDetailViewports(true)
return model
}
@@ -122,7 +131,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.height = msg.Height
m.resizeComponents()
m.ensureSelection()
m.syncIRCViewport(false)
m.syncDetailViewports(false)
return m, nil
case tea.KeyMsg:
switch msg.String() {
@@ -162,11 +171,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.authViewport, cmd = m.authViewport.Update(msg)
return m, cmd
}
if m.focus == focusChat {
if m.focus == focusIRC {
var cmd tea.Cmd
m.ircViewport, cmd = m.ircViewport.Update(msg)
return m, cmd
}
if m.focus == focusMiner {
var cmd tea.Cmd
m.minerViewport, cmd = m.minerViewport.Update(msg)
return m, cmd
}
return m, nil
}
@@ -189,11 +203,12 @@ func (m Model) updateAuth(msg AuthUpdate) (tea.Model, tea.Cmd) {
m.resolveErr = nil
m.streamers = loadingEntries(m.streamers)
m.ircDetails = make(map[string]ircDetail)
m.minerDetails = make(map[string][]string)
m.selectedConfig = ""
m.focus = focusStreamers
m.ensureSelection()
m.resizeComponents()
m.syncIRCViewport(true)
m.syncDetailViewports(true)
return m, startStreamerResolution(m.runtime, m.authState)
}
return m, nil
@@ -215,12 +230,15 @@ func (m Model) updateStreamer(msg StreamerUpdate) (tea.Model, tea.Cmd) {
if msg.IRC != nil {
m.applyIRCUpdate(*msg.IRC)
}
if msg.Miner != nil {
m.applyMinerUpdate(*msg.Miner)
}
if msg.Err != nil {
m.resolveErr = msg.Err
}
m.ensureSelection()
m.resizeComponents()
m.syncIRCViewport(false)
m.syncDetailViewports(false)
if msg.Done {
return m, nil
}
@@ -244,14 +262,20 @@ func (m *Model) applyIRCUpdate(update IRCUpdate) {
detail.joined = false
}
if message, ok := formatIRCChatLine(update.Line); ok {
detail.messages = append(detail.messages, message)
if len(detail.messages) > maxIRCMessageHistory {
detail.messages = append([]string(nil), detail.messages[len(detail.messages)-maxIRCMessageHistory:]...)
}
detail.messages = appendCappedHistory(detail.messages, message)
}
m.ircDetails[login] = detail
}
func (m *Model) applyMinerUpdate(update MinerUpdate) {
login := normalizeKey(update.Login)
line := strings.TrimSpace(update.Line)
if login == "" || line == "" {
return
}
m.minerDetails[login] = appendCappedHistory(m.minerDetails[login], line)
}
func (m *Model) ensureSelection() {
entries := m.orderedStreamers()
if len(entries) == 0 {
@@ -274,7 +298,7 @@ func (m *Model) moveSelection(delta int) {
entries := m.orderedStreamers()
if len(entries) == 0 {
m.selectedConfig = ""
m.syncIRCViewport(true)
m.syncDetailViewports(true)
return
}
@@ -288,7 +312,7 @@ func (m *Model) moveSelection(delta int) {
}
m.selectedConfig = entries[selected].ConfigLogin
if m.selectedConfig != current {
m.syncIRCViewport(true)
m.syncDetailViewports(true)
}
}
@@ -296,8 +320,10 @@ func (m *Model) moveFocusLeft() {
switch m.focus {
case focusInfo:
m.focus = focusStreamers
case focusChat:
case focusIRC:
m.focus = focusInfo
case focusMiner:
m.focus = focusIRC
}
}
@@ -306,7 +332,9 @@ func (m *Model) moveFocusRight() {
case focusStreamers:
m.focus = focusInfo
case focusInfo:
m.focus = focusChat
m.focus = focusIRC
case focusIRC:
m.focus = focusMiner
}
}
@@ -343,6 +371,8 @@ func (m *Model) resizeComponents() {
m.authViewport.Height = authViewportHeight(m.height)
m.ircViewport.Width = detailViewportWidth(m.width)
m.ircViewport.Height = detailViewportHeight(m.height)
m.minerViewport.Width = detailViewportWidth(m.width)
m.minerViewport.Height = detailViewportHeight(m.height)
}
func (m *Model) syncAuthViewport() {
@@ -369,6 +399,23 @@ func (m *Model) syncIRCViewport(forceBottom bool) {
}
}
func (m *Model) syncMinerViewport(forceBottom bool) {
if m.minerViewport.Width <= 0 || m.minerViewport.Height <= 0 {
return
}
stickToBottom := forceBottom || m.minerViewport.AtBottom()
m.minerViewport.SetContent(m.minerViewportContent())
if stickToBottom {
m.minerViewport.GotoBottom()
}
}
func (m *Model) syncDetailViewports(forceBottom bool) {
m.syncIRCViewport(forceBottom)
m.syncMinerViewport(forceBottom)
}
func (m Model) ircViewportContent() string {
entry, ok := m.selectedEntry()
if !ok {
@@ -388,6 +435,19 @@ func (m Model) ircViewportContent() string {
return strings.Join(detail.messages, "\n")
}
func (m Model) minerViewportContent() string {
entry, ok := m.selectedEntry()
if !ok {
return "No streamers configured"
}
logins := m.minerDetails[normalizeKey(entry.Login)]
if len(logins) == 0 {
return "Waiting for miner activity..."
}
return strings.Join(logins, "\n")
}
func (m Model) selectedEntry() (twitch.StreamerEntry, bool) {
entries := m.orderedStreamers()
selected := m.selectedRowIndex(entries)
@@ -398,10 +458,14 @@ func (m Model) selectedEntry() (twitch.StreamerEntry, bool) {
}
func (m Model) visibleDetailTab() detailTab {
if m.focus == focusChat {
switch m.focus {
case focusIRC:
return ircTab
case focusMiner:
return minerTab
default:
return infoTab
}
return infoTab
}
func (m Model) isStreamersFocused() bool {
@@ -409,7 +473,7 @@ func (m Model) isStreamersFocused() bool {
}
func (m Model) isRightPanelFocused() bool {
return m.focus == focusInfo || m.focus == focusChat
return m.focus == focusInfo || m.focus == focusIRC || m.focus == focusMiner
}
func isActive(entry twitch.StreamerEntry) bool {
@@ -450,6 +514,14 @@ func formatIRCChatLine(line string) (string, bool) {
return user + ": " + message, true
}
func appendCappedHistory(history []string, line string) []string {
history = append(history, line)
if len(history) <= maxIRCMessageHistory {
return history
}
return append([]string(nil), history[len(history)-maxIRCMessageHistory:]...)
}
func loadingEntries(entries []twitch.StreamerEntry) []twitch.StreamerEntry {
logins := make([]string, 0, len(entries))
for _, entry := range entries {
+131 -10
View File
@@ -37,6 +37,7 @@ func TestViewDisplaysDashboardWithSelectedStreamerDetails(t *testing.T) {
"Watching: alpha_live, beta_live",
"Info",
"IRC",
"Miner",
"live | irc idle",
"gamma",
"loading",
@@ -204,20 +205,32 @@ func TestFocusNavigationMovesBetweenPanels(t *testing.T) {
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight})
next = updated.(Model)
if next.focus != focusChat || next.visibleDetailTab() != ircTab {
t.Fatalf("focus after second right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusChat, ircTab)
if next.focus != focusIRC || next.visibleDetailTab() != ircTab {
t.Fatalf("focus after second right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusIRC, ircTab)
}
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight})
next = updated.(Model)
if next.focus != focusChat {
t.Fatalf("focus after right on chat = %v, want %v", next.focus, focusChat)
if next.focus != focusMiner || next.visibleDetailTab() != minerTab {
t.Fatalf("focus after third right = %v with tab %v, want %v and %v", next.focus, next.visibleDetailTab(), focusMiner, minerTab)
}
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyRight})
next = updated.(Model)
if next.focus != focusMiner {
t.Fatalf("focus after right on miner = %v, want %v", next.focus, focusMiner)
}
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft})
next = updated.(Model)
if next.focus != focusIRC {
t.Fatalf("focus after left from miner = %v, want %v", next.focus, focusIRC)
}
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft})
next = updated.(Model)
if next.focus != focusInfo {
t.Fatalf("focus after left from chat = %v, want %v", next.focus, focusInfo)
t.Fatalf("focus after left from irc = %v, want %v", next.focus, focusInfo)
}
updated, _ = next.Update(tea.KeyMsg{Type: tea.KeyLeft})
@@ -275,7 +288,7 @@ func TestIRCUpdatesShowJoinedStatusAndFormattedMessages(t *testing.T) {
Line: ":someone!someone@someone.tmi.twitch.tv PRIVMSG #alpha_live :hello there",
}})
next = updated.(Model)
next.focus = focusChat
next.focus = focusIRC
next.syncIRCViewport(true)
assertContainsAll(t, next.View(), "someone: hello there")
@@ -286,7 +299,7 @@ func TestChatFocusUsesUpDownForViewportScroll(t *testing.T) {
twitch.StreamerEntry{ConfigLogin: "alpha", Login: "alpha_live", Live: true, Status: twitch.StreamerReady},
twitch.StreamerEntry{ConfigLogin: "beta", Login: "beta_live", Status: twitch.StreamerReady},
)
model.focus = focusChat
model.focus = focusIRC
model.ircDetails["alpha_live"] = ircDetail{
joined: true,
messages: numberedMessages(20),
@@ -338,6 +351,56 @@ func TestIRCUpdatesKeepOnlyLast50ChatMessages(t *testing.T) {
}
}
func TestMinerUpdatesRenderAndKeepOnlyLast50Messages(t *testing.T) {
model := dashboardModel(twitch.StreamerEntry{
ConfigLogin: "alpha",
Login: "alpha_live",
Live: true,
Status: twitch.StreamerReady,
})
next := model
for i := 1; i <= maxIRCMessageHistory+5; i++ {
updated, _ := next.Update(StreamerUpdate{Miner: &MinerUpdate{
Login: "alpha_live",
Line: fmt.Sprintf("miner message %d", i),
}})
next = updated.(Model)
}
next.focus = focusMiner
next.syncMinerViewport(true)
logs := next.minerDetails["alpha_live"]
if len(logs) != maxIRCMessageHistory {
t.Fatalf("miner message count = %d, want %d", len(logs), maxIRCMessageHistory)
}
if logs[0] != "miner message 6" {
t.Fatalf("oldest retained miner message = %q", logs[0])
}
if logs[len(logs)-1] != "miner message 55" {
t.Fatalf("newest retained miner message = %q", logs[len(logs)-1])
}
assertContainsAll(t, next.View(), "miner message 55")
}
func TestMinerTabShowsHistoryForOfflineStreamer(t *testing.T) {
model := dashboardModel(twitch.StreamerEntry{
ConfigLogin: "alpha",
Login: "alpha_live",
Status: twitch.StreamerReady,
})
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
Login: "alpha_live",
Line: "pubsub stream down",
}})
next := updated.(Model)
next.focus = focusMiner
next.syncMinerViewport(true)
assertContainsAll(t, next.View(), "pubsub stream down")
}
func TestIRCUpdatesIgnoreNonChatProtocolLines(t *testing.T) {
model := dashboardModel(twitch.StreamerEntry{
ConfigLogin: "alpha",
@@ -363,7 +426,7 @@ func TestIRCViewportAutoScrollsAtBottom(t *testing.T) {
Live: true,
Status: twitch.StreamerReady,
})
model.focus = focusChat
model.focus = focusIRC
model.ircDetails["alpha_live"] = ircDetail{
joined: true,
messages: numberedMessages(20),
@@ -391,7 +454,7 @@ func TestIRCViewportPreservesManualScrollPosition(t *testing.T) {
Live: true,
Status: twitch.StreamerReady,
})
model.focus = focusChat
model.focus = focusIRC
model.ircDetails["alpha_live"] = ircDetail{
joined: true,
messages: numberedMessages(20),
@@ -412,6 +475,56 @@ func TestIRCViewportPreservesManualScrollPosition(t *testing.T) {
}
}
func TestMinerViewportAutoScrollsAtBottom(t *testing.T) {
model := dashboardModel(twitch.StreamerEntry{
ConfigLogin: "alpha",
Login: "alpha_live",
Live: true,
Status: twitch.StreamerReady,
})
model.focus = focusMiner
model.minerDetails["alpha_live"] = numberedMinerMessages(20)
model.syncMinerViewport(true)
if !model.minerViewport.AtBottom() {
t.Fatal("expected miner viewport to start at bottom")
}
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
Login: "alpha_live",
Line: "newest miner event",
}})
next := updated.(Model)
if !next.minerViewport.AtBottom() {
t.Fatal("expected miner viewport to stay at bottom after new message")
}
}
func TestMinerViewportPreservesManualScrollPosition(t *testing.T) {
model := dashboardModel(twitch.StreamerEntry{
ConfigLogin: "alpha",
Login: "alpha_live",
Live: true,
Status: twitch.StreamerReady,
})
model.focus = focusMiner
model.minerDetails["alpha_live"] = numberedMinerMessages(20)
model.syncMinerViewport(true)
model.minerViewport.GotoTop()
updated, _ := model.Update(StreamerUpdate{Miner: &MinerUpdate{
Login: "alpha_live",
Line: "newest miner event",
}})
next := updated.(Model)
if next.minerViewport.YOffset != 0 {
t.Fatalf("miner viewport YOffset = %d, want 0", next.minerViewport.YOffset)
}
if next.minerViewport.AtBottom() {
t.Fatal("expected miner viewport to remain off bottom after manual scroll")
}
}
func TestWindowSizeKeepsSelectionVisible(t *testing.T) {
model := dashboardModel(
twitch.StreamerEntry{ConfigLogin: "alpha", Status: twitch.StreamerReady},
@@ -432,7 +545,7 @@ func dashboardModel(entries ...twitch.StreamerEntry) Model {
model.width = 100
model.height = 28
model.resizeComponents()
model.syncIRCViewport(true)
model.syncDetailViewports(true)
return model
}
@@ -444,6 +557,14 @@ func numberedMessages(count int) []string {
return lines
}
func numberedMinerMessages(count int) []string {
lines := make([]string, 0, count)
for i := 1; i <= count; i++ {
lines = append(lines, fmt.Sprintf("miner message %d", i))
}
return lines
}
func assertContainsAll(t *testing.T, got string, wants ...string) {
t.Helper()
for _, want := range wants {
+16 -1
View File
@@ -136,7 +136,7 @@ func (r *runtime) startResolve(state *auth.State, ch chan<- StreamerUpdate) {
ch <- StreamerUpdate{Err: err, Done: true}
return
}
minerManager := miner.NewManager(r.ctx, service, nil)
minerManager := newMinerManager(r.ctx, service, ch)
defer minerManager.Close()
if err := resolveStreamerEntriesWithSleep(r.ctx, service, state, r.logins, ircManager, minerManager, func(update StreamerUpdate) {
@@ -208,6 +208,21 @@ func newIRCManager(ch chan<- StreamerUpdate) *irc.Manager {
}
}
func newMinerManager(ctx context.Context, service miner.Service, ch chan<- StreamerUpdate) *miner.Manager {
return miner.NewManager(ctx, service, nil, newMinerLogSink(ch))
}
func newMinerLogSink(ch chan<- StreamerUpdate) func(miner.LogEntry) {
return func(entry miner.LogEntry) {
ch <- StreamerUpdate{
Miner: &MinerUpdate{
Login: entry.Login,
Line: entry.Line,
},
}
}
}
func resolveStreamerEntries(ctx context.Context, service streamerService, state *auth.State, logins []string, ircSyncer ircSyncer, minerSyncer minerSyncer, send func(StreamerUpdate)) error {
return resolveStreamerEntriesWithSleep(ctx, service, state, logins, ircSyncer, minerSyncer, send, streamerRefreshInterval, sleepContext)
}
+18
View File
@@ -9,6 +9,7 @@ import (
"parasocial/internal/auth"
"parasocial/internal/irc"
"parasocial/internal/miner"
"parasocial/internal/twitch"
)
@@ -206,6 +207,23 @@ func TestResolveStreamerEntriesRefreshUpdatesWatchedChannels(t *testing.T) {
})
}
func TestNewMinerLogSinkBridgesMinerEntriesIntoStreamerUpdates(t *testing.T) {
ch := make(chan StreamerUpdate, 1)
newMinerLogSink(ch)(miner.LogEntry{
Login: "alpha_live",
Line: "pubsub points earned: balance=42",
})
update := <-ch
if update.Miner == nil {
t.Fatal("expected miner update")
}
if update.Miner.Login != "alpha_live" || update.Miner.Line != "pubsub points earned: balance=42" {
t.Fatalf("miner update = %#v", update.Miner)
}
}
type serviceOption func(*fakeStreamerService)
func streamService(options ...serviceOption) *fakeStreamerService {
+6
View File
@@ -67,3 +67,9 @@ func newIRCViewport(width, height int) viewport.Model {
vp.Style = lipgloss.NewStyle()
return vp
}
func newMinerViewport(width, height int) viewport.Model {
vp := viewport.New(width, height)
vp.Style = lipgloss.NewStyle()
return vp
}
+7
View File
@@ -85,6 +85,8 @@ func (m Model) renderDetailPanel() string {
switch m.visibleDetailTab() {
case ircTab:
body = m.renderIRCTab()
case minerTab:
body = m.renderMinerTab()
default:
body = m.renderInfoTab(entry)
}
@@ -95,6 +97,7 @@ func (m Model) renderDetailTabs() string {
tabs := []string{
m.renderDetailTabButton(infoTab, "Info"),
m.renderDetailTabButton(ircTab, "IRC"),
m.renderDetailTabButton(minerTab, "Miner"),
}
return lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
}
@@ -132,6 +135,10 @@ func (m Model) renderIRCTab() string {
return m.ircViewport.View()
}
func (m Model) renderMinerTab() string {
return m.minerViewport.View()
}
func (m Model) renderStreamerRows() string {
entries := m.visibleStreamers()
if len(entries) == 0 {