Skip to content

Commit d3abe4a

Browse files
bobakemamianclaude
andauthored
feat(board): live-stream stdout/stderr in the logs pane during a press (#149)
Before: pressing a button from the board executed it silently — the logs pane only showed PAST run summaries, not the current press's output as it happened. Now: when a press fires, the board 1. creates a LineSink channel and passes it into engine.Execute 2. auto-opens the logs pane so the live feed is visible 3. reads lines via a readFromSink tea.Cmd pub/sub loop 4. renders the last ~8 lines in the pane with stderr in red, stdout in primary, timestamps muted, a "● live" badge in the header flipping to "· done" when the stream closes Lines are capped at 200 per button so runaway scripts don't OOM the TUI; the tail is always freshest. The live buffer survives past press completion so the user can see what just ran — it's reset to empty only on the next press of that same button. runPress now accepts (and closes) the sink channel. Execute's existing LineSink interface did all the heavy lifting — this is a consumer, not a new engine feature. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ee1058 commit d3abe4a

1 file changed

Lines changed: 187 additions & 7 deletions

File tree

internal/tui/board.go

Lines changed: 187 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ type Model struct {
8585
// scrollable body. Clamped in movePaneCursor / handleKey.
8686
logsDetailScroll int
8787

88+
// liveLines buffers streaming stdout/stderr lines while a press
89+
// is in flight (and for a moment after it finishes so the user
90+
// can see what just ran). Keyed by button name, capped per
91+
// button via maxLiveLinesPerButton.
92+
liveLines map[string][]liveLine
93+
94+
// liveSinks holds the channels each in-flight press is writing
95+
// to. Keyed by button name. Update's liveLineMsg handler reaches
96+
// into this to re-schedule readFromSink and keep the stream
97+
// flowing. Entry removed on liveStreamDoneMsg.
98+
liveSinks map[string]chan engine.LogLine
99+
88100
// argForm, when non-nil, is the inline press-with-args prompt
89101
// replacing the board's content area. Opened automatically when
90102
// the user presses a button with required args; dismissed on
@@ -110,6 +122,30 @@ const (
110122
viewList // single-column text list
111123
)
112124

125+
// liveLine is one captured stream entry. Mirrors engine.LogLine but
126+
// kept distinct so board rendering code doesn't have to import engine
127+
// types just to format a row.
128+
type liveLine struct {
129+
Ts time.Time
130+
Sev engine.Severity
131+
Text string
132+
}
133+
134+
// liveLineMsg carries one streamed line into the Update loop. The
135+
// handler appends it to m.liveLines[name] and re-schedules
136+
// readFromSink so the stream stays flowing.
137+
type liveLineMsg struct {
138+
name string
139+
line engine.LogLine
140+
}
141+
142+
// liveStreamDoneMsg fires when the sink channel closes (runPress
143+
// closes it after engine.Execute returns). No follow-up readFromSink
144+
// is scheduled on receipt — that ends the pub/sub loop cleanly.
145+
type liveStreamDoneMsg struct {
146+
name string
147+
}
148+
113149
type pressDoneMsg struct {
114150
name string
115151
result *engine.Result
@@ -210,6 +246,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
210246
case pressDoneMsg:
211247
return m.handlePressDone(msg), nil
212248

249+
case liveLineMsg:
250+
// Append the incoming line and immediately schedule another
251+
// read so the stream keeps flowing. If the sink was cleaned
252+
// up (e.g. press completed between msg send and handle),
253+
// skip re-scheduling — the liveStreamDoneMsg path handles
254+
// cleanup.
255+
m.appendLiveLine(msg.name, liveLine{
256+
Ts: msg.line.Ts, Sev: msg.line.Sev, Text: msg.line.Text,
257+
})
258+
if sink, ok := m.liveSinks[msg.name]; ok {
259+
return m, readFromSink(sink, msg.name)
260+
}
261+
return m, nil
262+
263+
case liveStreamDoneMsg:
264+
// runPress closed the channel after Execute returned. Drop
265+
// the sink from the model so future live-line schedules
266+
// short-circuit. Keep liveLines intact — the user can still
267+
// scroll the just-finished output.
268+
if m.liveSinks != nil {
269+
delete(m.liveSinks, msg.name)
270+
}
271+
return m, nil
272+
213273
case pressFireMsg:
214274
// Only flip fire on if the pulse is still targeting the same
215275
// button. A rapid double-press would otherwise paint fire on a
@@ -645,7 +705,28 @@ func (m Model) pressButtonWithArgs(name string, args map[string]string) (tea.Mod
645705
}
646706
}
647707

648-
pressCmd := runPress(btn, codePath, batteries, args)
708+
// Live-stream sink: every stdout/stderr line the child writes gets
709+
// forwarded into the board's logs pane in real time. runPress
710+
// closes the channel when Execute returns, which terminates the
711+
// readFromSink Cmd loop below.
712+
sink := make(chan engine.LogLine, 128)
713+
if m.liveLines == nil {
714+
m.liveLines = map[string][]liveLine{}
715+
}
716+
if m.liveSinks == nil {
717+
m.liveSinks = map[string]chan engine.LogLine{}
718+
}
719+
// Reset any previous live buffer for this button — a new press
720+
// starts a new stream, not a continuation.
721+
m.liveLines[name] = nil
722+
m.liveSinks[name] = sink
723+
// Auto-open the logs pane so the live feed is visible without
724+
// the user having to remember the L toggle. They can still
725+
// close it with L once the press finishes.
726+
m.logsOpen = true
727+
728+
pressCmd := runPress(btn, codePath, batteries, args, sink)
729+
streamCmd := readFromSink(sink, name)
649730

650731
// Four-frame choreography: rest → keydown (now) → fire (+40ms) →
651732
// release (+180ms). pressPulse is set synchronously so the very
@@ -658,24 +739,63 @@ func (m Model) pressButtonWithArgs(name string, args map[string]string) (tea.Mod
658739

659740
if !m.ticking {
660741
m.ticking = true
661-
return m, tea.Batch(pressCmd, fireCmd, releaseCmd, tickCmd())
742+
return m, tea.Batch(pressCmd, streamCmd, fireCmd, releaseCmd, tickCmd())
662743
}
663-
return m, tea.Batch(pressCmd, fireCmd, releaseCmd)
744+
return m, tea.Batch(pressCmd, streamCmd, fireCmd, releaseCmd)
664745
}
665746

666-
func runPress(btn *button.Button, codePath string, batteries, args map[string]string) tea.Cmd {
747+
func runPress(btn *button.Button, codePath string, batteries, args map[string]string, sink chan engine.LogLine) tea.Cmd {
667748
name := btn.Name
668749
return func() tea.Msg {
669750
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(btn.TimeoutSeconds)*time.Second)
670751
defer cancel()
671752

672-
// Board presses don't stream yet — the dedicated `buttons logs`
673-
// viewer (C2) will pass a sink for live tailing.
674-
result := engine.Execute(ctx, btn, args, batteries, nil, codePath)
753+
result := engine.Execute(ctx, btn, args, batteries, sink, codePath)
754+
// Closing the sink signals readFromSink's Cmd loop that no
755+
// more lines are coming so it can return the terminal
756+
// liveStreamDoneMsg and stop recursing.
757+
if sink != nil {
758+
close(sink)
759+
}
675760
return pressDoneMsg{name: name, result: result}
676761
}
677762
}
678763

764+
// readFromSink reads ONE LogLine from the sink channel and returns
765+
// either a liveLineMsg (wrapping that line) or a liveStreamDoneMsg
766+
// when the channel closes. Each liveLineMsg handler re-schedules
767+
// readFromSink to keep the stream flowing without blocking Update.
768+
// Classic Bubble Tea pub/sub pattern.
769+
func readFromSink(sink <-chan engine.LogLine, name string) tea.Cmd {
770+
return func() tea.Msg {
771+
line, ok := <-sink
772+
if !ok {
773+
return liveStreamDoneMsg{name: name}
774+
}
775+
return liveLineMsg{name: name, line: line}
776+
}
777+
}
778+
779+
// maxLiveLinesPerButton caps how many streamed lines we keep in
780+
// memory per button. 200 is plenty for debugging a press in flight
781+
// while staying bounded for pathological scripts that flood stdout.
782+
const maxLiveLinesPerButton = 200
783+
784+
// appendLiveLine appends a streamed line to the button's buffer with
785+
// the cap enforced. Old lines roll off the front once the cap is
786+
// reached so the tail is always the freshest output.
787+
func (m *Model) appendLiveLine(name string, line liveLine) {
788+
if m.liveLines == nil {
789+
m.liveLines = map[string][]liveLine{}
790+
}
791+
buf := m.liveLines[name]
792+
buf = append(buf, line)
793+
if len(buf) > maxLiveLinesPerButton {
794+
buf = buf[len(buf)-maxLiveLinesPerButton:]
795+
}
796+
m.liveLines[name] = buf
797+
}
798+
679799
// tuiBatteryDiscoverer is the project-dir discoverer passed to
680800
// battery.NewServiceFromEnv — shared shape with the CLI helper but
681801
// defined here so the tui package doesn't reach into cmd.
@@ -1298,6 +1418,57 @@ func (m Model) renderChrome() string {
12981418
return strings.Repeat(" ", leftPad) + left + strings.Repeat(" ", gap) + right
12991419
}
13001420

1421+
// renderLiveLines paints the streamed stdout/stderr for an in-flight
1422+
// (or just-completed) press. Mirrors the full-screen logs viewer's
1423+
// severity coloring but in the compact pane layout — last N lines
1424+
// that fit in the pane's height. A header badge signals whether the
1425+
// stream is live or finished.
1426+
func (m Model) renderLiveLines(target string, lines []liveLine) string {
1427+
title := m.styles.HeroTitle.Render("logs")
1428+
running := m.status[target] == statusRunning
1429+
1430+
// Header badge tells the user what mode we're in. Orange ● when
1431+
// live, muted check when done.
1432+
var badge string
1433+
if running {
1434+
badge = m.styles.ChromeActiveBadge.Render("● live")
1435+
} else {
1436+
badge = m.styles.Muted.Render("· done")
1437+
}
1438+
1439+
header := title + m.styles.Muted.Render(" · "+target+" ") + badge
1440+
1441+
// Budget: pane is bounded, show the tail that fits. Cap at 8
1442+
// lines to keep the board composable with the rest of the
1443+
// chrome. Users who want more can drop to the CLI
1444+
// (`buttons NAME logs --follow`).
1445+
tail := 8
1446+
if len(lines) < tail {
1447+
tail = len(lines)
1448+
}
1449+
visible := lines[len(lines)-tail:]
1450+
1451+
previewBudget := m.width - leftPad - 2 - 16 // indent + ts column
1452+
if previewBudget < 20 {
1453+
previewBudget = 20
1454+
}
1455+
1456+
rendered := []string{header, ""}
1457+
for _, ln := range visible {
1458+
ts := m.styles.Muted.Render(ln.Ts.Local().Format("15:04:05"))
1459+
text := truncateDisplay(ln.Text, previewBudget)
1460+
var styled string
1461+
switch ln.Sev {
1462+
case engine.SeverityStderr:
1463+
styled = m.styles.StatusError.Render(text)
1464+
default:
1465+
styled = m.styles.ButtonName.Render(text)
1466+
}
1467+
rendered = append(rendered, fmt.Sprintf(" %s %s", ts, styled))
1468+
}
1469+
return indentBlock(strings.Join(rendered, "\n"), leftPad)
1470+
}
1471+
13011472
// renderLogs renders the collapsible history pane that sits above the
13021473
// footer when `l` has been toggled on. Scope is the button currently
13031474
// under the cursor — users looking at a row expect to see its runs,
@@ -1314,6 +1485,15 @@ func (m Model) renderLogs() string {
13141485
return indentBlock(title+"\n\n"+empty, leftPad)
13151486
}
13161487

1488+
// Live-follow mode: if we have buffered stream lines for this
1489+
// button (press in flight, or just completed and still showing),
1490+
// render them instead of the past-runs summary. The history
1491+
// view reappears on the next press's reset or when the user
1492+
// navigates to a button with no live buffer.
1493+
if lines := m.liveLines[target]; len(lines) > 0 {
1494+
return m.renderLiveLines(target, lines)
1495+
}
1496+
13171497
runs, err := history.List(target, logsPaneLimit)
13181498
if err != nil || len(runs) == 0 {
13191499
empty := m.styles.Muted.Render(

0 commit comments

Comments
 (0)