Commit b4c6cdb

Eric Bower  ·  2026-08-17 10:20:53 -0400 EDT
parent 2b62b2d
chore(ui): each task get 5 lines of history
3 files changed,  +120, -13
M run.sh
+53, -7
  1@@ -32,6 +32,7 @@ type TaskUIState struct {
  2 	Duration      time.Duration
  3 	ExitCode      string
  4 	LastOutput    string   // Last non-empty line for running peek
  5+	RunningOutput []string // Captured recent output lines while running (up to 5)
  6 	ErrorPreview  []string // Captured error lines on failure
  7 	PredictedDur  time.Duration
  8 	PrevStatus    string
  9@@ -134,15 +135,30 @@ func truncateLine(s string, maxWidth int) string {
 10 	return string(runes[:maxWidth])
 11 }
 12 
 13-func extractLastOutputLine(raw string) string {
 14+func extractLastOutputLines(raw string, maxLines int) []string {
 15 	clean := stripANSI(raw)
 16 	lines := strings.Split(clean, "\n")
 17-	for i := len(lines) - 1; i >= 0; i-- {
 18-		line := strings.TrimSpace(lines[i])
 19+	var nonEmpties []string
 20+	for _, l := range lines {
 21+		line := strings.TrimSpace(l)
 22 		if line != "" && !strings.HasPrefix(line, "step starting") && !strings.HasPrefix(line, "step completed") {
 23-			return line
 24+			nonEmpties = append(nonEmpties, line)
 25 		}
 26 	}
 27+	if len(nonEmpties) == 0 {
 28+		return nil
 29+	}
 30+	if maxLines > 0 && len(nonEmpties) > maxLines {
 31+		nonEmpties = nonEmpties[len(nonEmpties)-maxLines:]
 32+	}
 33+	return nonEmpties
 34+}
 35+
 36+func extractLastOutputLine(raw string) string {
 37+	lines := extractLastOutputLines(raw, 1)
 38+	if len(lines) > 0 {
 39+		return lines[0]
 40+	}
 41 	return ""
 42 }
 43 
 44@@ -339,7 +355,15 @@ func (ui *PipelineUIState) Render(frame int) string {
 45 			}
 46 
 47 			fmt.Fprintf(&sb, "  %c %-8s %s %s%s\n", spinnerRune, task.Name, formatShortDuration(taskDur), predStr, barPart)
 48-			if task.LastOutput != "" {
 49+			if len(task.RunningOutput) > 0 {
 50+				for i, line := range task.RunningOutput {
 51+					if i == len(task.RunningOutput)-1 {
 52+						fmt.Fprintf(&sb, "    └─ %s\n", line)
 53+					} else {
 54+						fmt.Fprintf(&sb, "    │  %s\n", line)
 55+					}
 56+				}
 57+			} else if task.LastOutput != "" {
 58 				fmt.Fprintf(&sb, "    └─ %s\n", task.LastOutput)
 59 			}
 60 
 61@@ -440,7 +464,11 @@ func (ui *PipelineUIState) RenderSummary(reportPath string, exitCode int) string
 62 				if task.ExitCode == "" {
 63 					exitStr = "exit 1"
 64 				}
 65-				fmt.Fprintf(&sb, "  ✖ %-9s %6s   %s (see error preview above)\n", task.Name, formatShortDuration(task.Duration), exitStr)
 66+				fmt.Fprintf(&sb, "  ✖ %-9s %6s   %s\n", task.Name, formatShortDuration(task.Duration), exitStr)
 67+				if len(task.ErrorPreview) > 0 {
 68+					sb.WriteString(renderErrorPreviewBox(task.ErrorPreview, 65))
 69+					sb.WriteString("\n")
 70+				}
 71 			default:
 72 				fmt.Fprintf(&sb, "  ✖ %-9s cancelled\n", task.Name)
 73 			}
 74@@ -754,7 +782,10 @@ func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, bra
 75 				ctrl.ui.mu.Lock()
 76 				if task, ok := ctrl.ui.Tasks[s.Short]; ok {
 77 					if s.Ended == "" {
 78-						task.LastOutput = extractLastOutputLine(hist)
 79+						task.RunningOutput = extractLastOutputLines(hist, 5)
 80+						if len(task.RunningOutput) > 0 {
 81+							task.LastOutput = task.RunningOutput[len(task.RunningOutput)-1]
 82+						}
 83 					} else if s.ExitCode != "0" && len(task.ErrorPreview) == 0 {
 84 						task.ErrorPreview = extractErrorPreview(hist, 5)
 85 					}
 86@@ -797,6 +828,21 @@ func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, eventType, bra
 87 		}
 88 	}
 89 
 90+	for _, s := range finalSessions {
 91+		if s.Short == "runner" || s.Short == "" {
 92+			continue
 93+		}
 94+		if s.ExitCode != "0" && s.ExitCode != "" {
 95+			ctrl.ui.mu.Lock()
 96+			if task, ok := ctrl.ui.Tasks[s.Short]; ok && len(task.ErrorPreview) == 0 {
 97+				if hist, err := fetchHistoryPlain(s.Name); err == nil && hist != "" {
 98+					task.ErrorPreview = extractErrorPreview(hist, 5)
 99+				}
100+			}
101+			ctrl.ui.mu.Unlock()
102+		}
103+	}
104+
105 	exitCode, overallStatus := resolveJobExitCode(finalSessions)
106 
107 	// Update historical stats.json
+66, -5
  1@@ -91,10 +91,18 @@ func TestUI_EarlyFailurePeekBox(t *testing.T) {
  2 }
  3 
  4 func TestUI_LiveActivityPeekExtraction(t *testing.T) {
  5-	rawHistory := "step starting\n\n   \x1b[32mrunning golangci-lint on 28 packages...\x1b[0m\n\n"
  6+	rawHistory := "step starting\n\n   \x1b[32mline 1\x1b[0m\nline 2\nline 3\nline 4\nline 5\nline 6\n\n"
  7 	peek := extractLastOutputLine(rawHistory)
  8-	if peek != "running golangci-lint on 28 packages..." {
  9-		t.Errorf("expected clean peek line, got '%s'", peek)
 10+	if peek != "line 6" {
 11+		t.Errorf("expected clean peek line 'line 6', got '%s'", peek)
 12+	}
 13+
 14+	lines := extractLastOutputLines(rawHistory, 5)
 15+	if len(lines) != 5 {
 16+		t.Fatalf("expected 5 lines, got %d", len(lines))
 17+	}
 18+	if lines[0] != "line 2" || lines[4] != "line 6" {
 19+		t.Errorf("unexpected extracted lines: %v", lines)
 20 	}
 21 
 22 	emptyHistory := "   \n\n\x1b[0m\n"
 23@@ -102,6 +110,10 @@ func TestUI_LiveActivityPeekExtraction(t *testing.T) {
 24 	if peekEmpty != "" {
 25 		t.Errorf("expected empty peek, got '%s'", peekEmpty)
 26 	}
 27+	linesEmpty := extractLastOutputLines(emptyHistory, 5)
 28+	if linesEmpty != nil {
 29+		t.Errorf("expected nil lines, got %v", linesEmpty)
 30+	}
 31 }
 32 
 33 func TestUI_RenderStateTransitions(t *testing.T) {
 34@@ -128,14 +140,23 @@ func TestUI_RenderStateTransitions(t *testing.T) {
 35 	ui.Tasks["fmt"].Duration = 400 * time.Millisecond
 36 	ui.Tasks["lint"].Status = "running"
 37 	ui.Tasks["lint"].Duration = 5400 * time.Millisecond
 38-	ui.Tasks["lint"].LastOutput = "running golangci-lint on 28 packages..."
 39+	ui.Tasks["lint"].RunningOutput = []string{
 40+		"fetching dependencies...",
 41+		"building ast...",
 42+		"running checkers...",
 43+		"analyzing packages...",
 44+		"running golangci-lint on 28 packages...",
 45+	}
 46 
 47 	render2 := ui.Render(0)
 48 	if !strings.Contains(render2, "fmt") || !strings.Contains(render2, "lint") {
 49 		t.Errorf("expected State 2 running tasks, got:\n%s", render2)
 50 	}
 51+	if !strings.Contains(render2, "│  fetching dependencies...") {
 52+		t.Errorf("expected vertical bar prefix in running history, got:\n%s", render2)
 53+	}
 54 	if !strings.Contains(render2, "└─ running golangci-lint on 28 packages...") {
 55-		t.Errorf("expected live activity peek under lint, got:\n%s", render2)
 56+		t.Errorf("expected last line with corner branch under lint, got:\n%s", render2)
 57 	}
 58 
 59 	// State 3: Failure with error preview
 60@@ -166,6 +187,46 @@ func TestUI_RenderStateTransitions(t *testing.T) {
 61 	if !strings.Contains(summary, "Report: /tmp/pici-artifacts/my-repo/job123/index.html") {
 62 		t.Errorf("expected report path in summary, got:\n%s", summary)
 63 	}
 64+	if !strings.Contains(summary, "cmd/server/main.go:58:2: errcheck: unchecked error") {
 65+		t.Errorf("expected error preview box in summary, got:\n%s", summary)
 66+	}
 67+}
 68+
 69+func TestUI_RenderSummary_FailureWithErrorPreview(t *testing.T) {
 70+	ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", nil)
 71+	ui.TaskOrder = []string{"fmt", "lint", "test"}
 72+	ui.Tasks["fmt"] = &TaskUIState{Name: "fmt", Status: "success", Duration: 500 * time.Millisecond}
 73+	ui.Tasks["lint"] = &TaskUIState{
 74+		Name:     "lint",
 75+		Status:   "failed",
 76+		ExitCode: "1",
 77+		Duration: 1200 * time.Millisecond,
 78+		ErrorPreview: []string{
 79+			"line 1: syntax error",
 80+			"line 2: undefined symbol foo",
 81+		},
 82+	}
 83+	ui.Tasks["test"] = &TaskUIState{
 84+		Name:     "test",
 85+		Status:   "failed",
 86+		ExitCode: "2",
 87+		Duration: 3400 * time.Millisecond,
 88+		ErrorPreview: []string{
 89+			"--- FAIL: TestSomething",
 90+			"    main_test.go:42: assertion failed",
 91+		},
 92+	}
 93+
 94+	summary := ui.RenderSummary("/artifacts/index.html", 1)
 95+	if !strings.Contains(summary, "2 tasks failed") {
 96+		t.Errorf("expected 2 tasks failed, got:\n%s", summary)
 97+	}
 98+	if !strings.Contains(summary, "syntax error") {
 99+		t.Errorf("expected lint error preview in summary, got:\n%s", summary)
100+	}
101+	if !strings.Contains(summary, "FAIL: TestSomething") {
102+		t.Errorf("expected test error preview in summary, got:\n%s", summary)
103+	}
104 }
105 
106 func TestUI_RenderSummary_SuccessWithFixed(t *testing.T) {
M run.sh
+1, -1
1@@ -8,7 +8,7 @@ while true; do
2   echo "waiting for pici event ..."
3   event=$(ssh pipe sub pici 2>/dev/null)
4   if [ -n "$event" ]; then
5-      echo "$event" | pici runner --wait
6+      echo "$event" | pici runner
7   else
8       echo "could not connect to pipe, waiting 5s..."
9       sleep 5