Skip to content

Commit 61dae4e

Browse files
juergen-kcclaude
andcommitted
feat(tui): show recipe step output and make it scrollable
Running a report-style recipe (stale-device-cleanup, compliance-report, audit-*, etc.) from the TUI was hiding the actual data — only the "Stale device scan complete. Review devices..." completion message rendered, which is useless when the devices themselves aren't visible. - Render each step's captured stdout inline below its status line on done - Viewport-based scrolling (j/k/up/down, g/G, PgUp/PgDn, space) because altscreen mode disables terminal scrollback - Scroll indicator shows position + range when content overflows - Skipped steps omit the empty-output block Also renames RecipeParamFormScreen.Title() from "Run: X" to "Configure: X" so the breadcrumb no longer reads `... > Run: X > Run: X`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c5280c7 commit 61dae4e

5 files changed

Lines changed: 244 additions & 25 deletions

File tree

internal/tui/screen/recipe_list_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func TestRecipeListScreen_EnterPushesParamForm(t *testing.T) {
8282
// Recipes are sorted alphabetically in LoadAll, but our fake bypasses that;
8383
// accept either as long as it's one of our recipes.
8484
title := pushMsg.Screen.Title()
85-
if title != "Run: security-audit" && title != "Run: onboard-user" {
85+
if title != "Configure: security-audit" && title != "Configure: onboard-user" {
8686
t.Errorf("unexpected pushed screen title %q", title)
8787
}
8888
}

internal/tui/screen/recipe_param.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ func NewRecipeParamFormScreen(r *recipe.Recipe) *RecipeParamFormScreen {
5151
}
5252

5353
func (s *RecipeParamFormScreen) Title() string {
54-
return "Run: " + s.recipe.Name
54+
return "Configure: " + s.recipe.Name
5555
}
5656

5757
func (s *RecipeParamFormScreen) TextInputActive() bool {
@@ -153,7 +153,7 @@ func (s *RecipeParamFormScreen) submit() (tea.Model, tea.Cmd) {
153153

154154
func (s *RecipeParamFormScreen) View() string {
155155
var sb strings.Builder
156-
sb.WriteString(style.Title.Render("Run: " + s.recipe.Name))
156+
sb.WriteString(style.Title.Render("Configure: " + s.recipe.Name))
157157
sb.WriteString("\n")
158158

159159
if s.recipe.Description != "" {

internal/tui/screen/recipe_param_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
func TestRecipeParamFormScreen_TitleIncludesRecipe(t *testing.T) {
1313
r := &recipe.Recipe{Name: "onboard-user", Steps: []recipe.Step{{Name: "x", Command: "y"}}}
1414
s := NewRecipeParamFormScreen(r)
15-
if s.Title() != "Run: onboard-user" {
15+
if s.Title() != "Configure: onboard-user" {
1616
t.Errorf("Title = %q", s.Title())
1717
}
1818
}
@@ -105,7 +105,7 @@ func TestRecipeParamFormScreen_SubmitPushesRunScreen(t *testing.T) {
105105
t.Fatalf("expected PushScreenMsg, got %T", msg)
106106
}
107107
if !strings.HasPrefix(push.Screen.Title(), "Run: ") {
108-
t.Errorf("pushed title = %q, want prefix 'Run: '", push.Screen.Title())
108+
t.Errorf("pushed title = %q, want prefix 'Run: ' (the run screen, not the configure screen)", push.Screen.Title())
109109
}
110110
}
111111

internal/tui/screen/recipe_run.go

Lines changed: 138 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

internal/tui/screen/recipe_run_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,104 @@ func TestRecipeRunScreen_DoneMsgSetsResult(t *testing.T) {
142142
t.Errorf("view should show completion message; got:\n%s", view)
143143
}
144144
}
145+
146+
func TestRecipeRunScreen_ShowsStepOutput(t *testing.T) {
147+
r := &recipe.Recipe{
148+
Name: "audit",
149+
Steps: []recipe.Step{
150+
{Name: "list-devices", Command: "devices list -t"},
151+
},
152+
}
153+
s := NewRecipeRunScreen(r, nil, false)
154+
// Mark the step done and inject a done message with captured output.
155+
s.steps[0].status = "done"
156+
s.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
157+
s.Update(recipeDoneMsg{
158+
result: &recipe.ExecutionResult{
159+
Recipe: "audit",
160+
Status: "success",
161+
Steps: []recipe.StepResult{
162+
{Name: "list-devices", Status: "success",
163+
Output: "HOSTNAME OS\nfoo Mac\nbar Windows\n"},
164+
},
165+
Message: "done",
166+
},
167+
})
168+
169+
view := s.View()
170+
if !strings.Contains(view, "HOSTNAME") {
171+
t.Errorf("view should contain captured output header 'HOSTNAME'; got:\n%s", view)
172+
}
173+
if !strings.Contains(view, "foo") || !strings.Contains(view, "bar") {
174+
t.Errorf("view should contain output rows; got:\n%s", view)
175+
}
176+
}
177+
178+
func TestRecipeRunScreen_ScrollOffsetJKMoves(t *testing.T) {
179+
// Build an output large enough to force scrolling.
180+
var many []string
181+
for i := 0; i < 50; i++ {
182+
many = append(many, "line"+string(rune('A'+i%26)))
183+
}
184+
longOutput := strings.Join(many, "\n")
185+
186+
r := &recipe.Recipe{
187+
Name: "t",
188+
Steps: []recipe.Step{{Name: "s", Command: "c"}},
189+
}
190+
s := NewRecipeRunScreen(r, nil, false)
191+
s.steps[0].status = "done"
192+
s.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) // viewport ~16 lines
193+
s.Update(recipeDoneMsg{
194+
result: &recipe.ExecutionResult{
195+
Recipe: "t",
196+
Status: "success",
197+
Steps: []recipe.StepResult{{Name: "s", Status: "success", Output: longOutput}},
198+
},
199+
})
200+
201+
initial := s.scrollOffset
202+
s.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
203+
if s.scrollOffset != initial+1 {
204+
t.Errorf("after j, scrollOffset = %d, want %d", s.scrollOffset, initial+1)
205+
}
206+
s.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
207+
if s.scrollOffset != initial {
208+
t.Errorf("after k, scrollOffset = %d, want %d", s.scrollOffset, initial)
209+
}
210+
// Scroll to bottom with G, then ensure offset is clamped (View calls clampScroll).
211+
s.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")})
212+
_ = s.View() // triggers clampScroll
213+
if s.scrollOffset > 50 {
214+
t.Errorf("scrollOffset after G+View should be clamped; got %d", s.scrollOffset)
215+
}
216+
// Back to top with g.
217+
s.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")})
218+
if s.scrollOffset != 0 {
219+
t.Errorf("after g, scrollOffset = %d, want 0", s.scrollOffset)
220+
}
221+
}
222+
223+
func TestRecipeRunScreen_SkippedStepHasNoOutput(t *testing.T) {
224+
r := &recipe.Recipe{
225+
Name: "t",
226+
Steps: []recipe.Step{
227+
{Name: "conditional", Command: "c", When: "{{ .flag }}"},
228+
},
229+
}
230+
s := NewRecipeRunScreen(r, nil, false)
231+
s.steps[0].status = "skipped"
232+
s.Update(tea.WindowSizeMsg{Width: 80, Height: 40})
233+
s.Update(recipeDoneMsg{
234+
result: &recipe.ExecutionResult{
235+
Recipe: "t",
236+
Status: "success",
237+
Steps: []recipe.StepResult{{Name: "conditional", Status: "skipped"}},
238+
},
239+
})
240+
241+
view := s.View()
242+
if !strings.Contains(view, "conditional") {
243+
t.Errorf("view should still list skipped step name; got:\n%s", view)
244+
}
245+
}

0 commit comments

Comments
 (0)