@@ -74,6 +74,10 @@ type RecipeRunScreen struct {
7474
7575 width int
7676 height int
77+
78+ // scrollOffset controls the first visible line of the scrollable body.
79+ // Updated on j/k/up/down/page keys; clamped in clampScroll.
80+ scrollOffset int
7781}
7882
7983// NewRecipeRunScreen creates the run screen. When planMode is true, the screen
@@ -180,6 +184,30 @@ func (s *RecipeRunScreen) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
180184 if s .done {
181185 return s , func () tea.Msg { return tui.PopScreenMsg {} }
182186 }
187+ case "j" , "down" :
188+ s .scrollOffset ++
189+ return s , nil
190+ case "k" , "up" :
191+ s .scrollOffset --
192+ if s .scrollOffset < 0 {
193+ s .scrollOffset = 0
194+ }
195+ return s , nil
196+ case "g" :
197+ s .scrollOffset = 0
198+ return s , nil
199+ case "G" :
200+ s .scrollOffset = 1 << 30 // clamped in View
201+ return s , nil
202+ case "pgdown" , " " :
203+ s .scrollOffset += s .viewportHeight ()
204+ return s , nil
205+ case "pgup" :
206+ s .scrollOffset -= s .viewportHeight ()
207+ if s .scrollOffset < 0 {
208+ s .scrollOffset = 0
209+ }
210+ return s , nil
183211 }
184212
185213 case recipeLineMsg :
@@ -250,25 +278,43 @@ func RegisterTeaProgram(p interface{ Send(tea.Msg) }) {
250278 teaProgramSend = p .Send
251279}
252280
253- func (s * RecipeRunScreen ) View () string {
254- var sb strings.Builder
281+ // viewportHeight returns how many lines are available for the scrollable
282+ // body (everything except the fixed title and footer rows).
283+ func (s * RecipeRunScreen ) viewportHeight () int {
284+ // Title + blank + footer = 3 lines; plus 1 for scroll indicator when needed.
285+ reserved := 4
286+ h := s .height - reserved
287+ if h < 5 {
288+ h = 5
289+ }
290+ return h
291+ }
255292
293+ // buildBodyLines assembles the scrollable body: per-step status, step output,
294+ // final error/summary message. Returns a slice of already-styled lines.
295+ func (s * RecipeRunScreen ) buildBodyLines () []string {
256296 if s .planMode {
257- sb .WriteString (style .Title .Render ("Plan: " + s .recipe .Name ))
258- sb .WriteString ("\n \n " )
297+ out := []string {}
259298 if s .err != "" {
260- sb . WriteString ( style .Error .Render (" " + s .err ))
299+ out = append ( out , style .Error .Render (" " + s .err ))
261300 } else {
262- sb .WriteString (s .planText )
301+ for _ , line := range strings .Split (strings .TrimRight (s .planText , "\n " ), "\n " ) {
302+ out = append (out , line )
303+ }
263304 }
264- sb .WriteString ("\n " )
265- sb .WriteString (style .DimRow .Render (" enter: back esc: back" ))
266- return sb .String ()
305+ return out
267306 }
268307
269- sb .WriteString (style .Title .Render ("Run: " + s .recipe .Name ))
270- sb .WriteString ("\n \n " )
308+ // Build a quick lookup from step name → captured output, available only
309+ // after the recipe finishes (recipeDoneMsg populates s.result).
310+ outputs := map [string ]string {}
311+ if s .result != nil {
312+ for _ , sr := range s .result .Steps {
313+ outputs [sr .Name ] = sr .Output
314+ }
315+ }
271316
317+ var lines []string
272318 for _ , st := range s .steps {
273319 icon := "○"
274320 lineStyle := style .DimRow
@@ -286,27 +332,99 @@ func (s *RecipeRunScreen) View() string {
286332 icon = "✗"
287333 lineStyle = style .Error
288334 }
289- sb .WriteString (lineStyle .Render (fmt .Sprintf (" %s %s" , icon , st .name )))
290- sb .WriteString ("\n " )
335+ lines = append (lines , lineStyle .Render (fmt .Sprintf (" %s %s" , icon , st .name )))
336+
337+ // Show captured output below the step line once available. Keep the
338+ // "skipped" case output-free (engine doesn't produce output for
339+ // skipped steps, and showing empty whitespace is just noise).
340+ if out , ok := outputs [st .name ]; ok && st .status != "skipped" {
341+ for _ , line := range strings .Split (strings .TrimRight (out , "\n " ), "\n " ) {
342+ if line == "" {
343+ lines = append (lines , "" )
344+ continue
345+ }
346+ lines = append (lines , " " + line )
347+ }
348+ if st .status == "done" || st .status == "failed" {
349+ lines = append (lines , "" )
350+ }
351+ }
291352 }
292353
293- sb .WriteString ("\n " )
294-
295354 if s .err != "" {
296- sb . WriteString ( style . Error . Render ( " " + s . err ) )
297- sb . WriteString ( " \n " )
355+ lines = append ( lines , "" )
356+ lines = append ( lines , style . Error . Render ( " " + s . err ) )
298357 }
299358
300359 if s .done && s .result != nil && s .result .Message != "" {
301- sb .WriteString (style .Category .Render (" " + s .result .Message ))
360+ lines = append (lines , "" )
361+ lines = append (lines , style .Category .Render (" " + s .result .Message ))
362+ }
363+
364+ return lines
365+ }
366+
367+ // clampScroll constrains scrollOffset to [0, max(0, totalLines - viewport)].
368+ func (s * RecipeRunScreen ) clampScroll (totalLines int ) {
369+ max := totalLines - s .viewportHeight ()
370+ if max < 0 {
371+ max = 0
372+ }
373+ if s .scrollOffset > max {
374+ s .scrollOffset = max
375+ }
376+ if s .scrollOffset < 0 {
377+ s .scrollOffset = 0
378+ }
379+ }
380+
381+ func (s * RecipeRunScreen ) View () string {
382+ var sb strings.Builder
383+
384+ // Title.
385+ if s .planMode {
386+ sb .WriteString (style .Title .Render ("Plan: " + s .recipe .Name ))
387+ } else {
388+ sb .WriteString (style .Title .Render ("Run: " + s .recipe .Name ))
389+ }
390+ sb .WriteString ("\n \n " )
391+
392+ // Body (scrollable).
393+ body := s .buildBodyLines ()
394+ s .clampScroll (len (body ))
395+
396+ vh := s .viewportHeight ()
397+ start := s .scrollOffset
398+ end := start + vh
399+ if end > len (body ) {
400+ end = len (body )
401+ }
402+ for i := start ; i < end ; i ++ {
403+ sb .WriteString (body [i ])
302404 sb .WriteString ("\n " )
303405 }
406+ // Fill short bodies so footer doesn't jump.
407+ for i := end - start ; i < vh ; i ++ {
408+ sb .WriteString ("\n " )
409+ }
410+
411+ // Scroll indicator + keys footer.
412+ scrollHint := ""
413+ if len (body ) > vh {
414+ scrollHint = fmt .Sprintf (" [%d-%d of %d] j/k: scroll g/G: top/bottom" , start + 1 , end , len (body ))
415+ }
304416
417+ var keys string
305418 if s .done {
306- sb . WriteString ( style . DimRow . Render ( " enter: back esc: back"))
419+ keys = " enter: back esc: back"
307420 } else {
308- sb .WriteString (style .DimRow .Render (" esc: back (running in background)" ))
421+ keys = "esc: back (running in background)"
422+ }
423+ if scrollHint != "" {
424+ sb .WriteString (style .DimRow .Render (scrollHint ))
425+ sb .WriteString ("\n " )
309426 }
427+ sb .WriteString (style .DimRow .Render (" " + keys ))
310428
311429 return sb .String ()
312430}
0 commit comments