@@ -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+
113149type 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