diff --git a/README.md b/README.md index 6614efd..684b984 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,11 @@ own front-end settings are separate; see [Configuration](#configuration). `alt+↑`/`alt+↓` jump turn-to-turn, reasoning accordions auto-expand live and collapse on the next turn, and `^E` expands every tool step's details. - **Typed tool renderers** — diffs tint with a `+N −M` chip, file reads get - line numbers, JSON pretty-prints, test runs show pass/fail verdicts. + line numbers, JSON pretty-prints, and step lines earn typed chips from + structured output only: test verdicts (`✓ 5 passed · 2 skipped`, go + coverage), git commits/pushes (`⎇ a1b2c3d`, `↑ main`), lint results, + compiler warning counts, HTTP statuses, and search hit counts. Prose like + "Build passed" never goes green. - **Streaming answers** rendered as Markdown ([glamour](https://github.com/charmbracelet/glamour)). - **Tool activity** — every `tool_call`/`tool_result` shown live with a glyph diff --git a/internal/tui/renderers.go b/internal/tui/renderers.go index 9261c75..6b74794 100644 --- a/internal/tui/renderers.go +++ b/internal/tui/renderers.go @@ -4,6 +4,9 @@ import ( "bytes" "encoding/json" "fmt" + "math" + "regexp" + "strconv" "strings" ) @@ -253,38 +256,165 @@ func renderJSON(s string, width int, th theme) []string { // ── test-run summary ──────────────────────────────────────────────────────── +// Structured pass/fail patterns — only real runner output matches. Loose +// words ("Build passed", a stray "ok" progress line, the word "testsuite") +// must never produce a verdict chip. +var ( + // go test: "ok \tpkg\t0.5s" / "ok \tpkg\t(cached)" + goPassRe = regexp.MustCompile(`^ok[ \t]+\S+[ \t]+(?:\d+(?:\.\d+)?s|\(cached\))`) + // TAP: "ok 1 - description" + tapPassRe = regexp.MustCompile(`^ok[ \t]+\d+[ \t]+-`) + // Counted verdicts: pytest "1 failed, 4 passed in 0.1s", jest + // "Tests: 3 passed, 3 total", vitest "Tests 5 passed (5)", cargo + // "0 passed; 2 ignored". + countedRe = regexp.MustCompile(`\b(\d+) (passed|failed|skipped|ignored)\b`) + // go -cover: "coverage: 82.3% of statements", also trailing on ok lines. + coverageRe = regexp.MustCompile(`(\d+(?:\.\d+)?)% of statements`) + // Counted verdicts only fire on summary-shaped lines — bare "N passed" + // in prose ("10 files failed validation, 5 passed") must not produce + // verdicts. Jest ("Tests:"), vitest ("Tests "), cargo ("test result:"), + // or a pytest-style duration tail ("in 0.12s"). + countSummaryRe = regexp.MustCompile(`^tests?[ :]|^test result:|in \d+(?:\.\d+)?s?(?: =+)?$`) + // sanitize() strips ESC bytes but leaves SGR residue ("[32m") that glues + // digits and defeats line anchors; chips match on stripped text. + sgrResidueRe = regexp.MustCompile(`(?:\x1b)?\[[0-9;]+m`) +) + +// Arg-gated chip patterns: they fire only when the command words say the +// step actually ran the tool, so output that merely mentions a hash or an +// HTTP status never grows a chip. +var ( + commitLineRe = regexp.MustCompile(`^\[(.+?)\][ \t]*(.*)$`) + commitHashRe = regexp.MustCompile(`([0-9a-f]{7,40})\z`) + gitPushRe = regexp.MustCompile(`[0-9a-f]{7,40}\.{2,3}[0-9a-f]{7,40}[ \t]+(\S+)[ \t]+->`) + gitNewRefRe = regexp.MustCompile(`\[(?:new branch|new tag)\][ \t]+(\S+)[ \t]+->`) + lintIssuesRe = regexp.MustCompile(`^(\d+) issues?\.?:?$`) + eslintProblemsRe = regexp.MustCompile(`✖ (\d+) problems`) + warnEmittedRe = regexp.MustCompile(`^warning: (\d+) warnings? emitted\.?$`) + warnGeneratedRe = regexp.MustCompile(`^(\d+) warnings? generated\.?$`) + httpStatusRe = regexp.MustCompile(`(?i)^HTTP/[\d.]+ (\d{3})`) + wgetStatusRe = regexp.MustCompile(`awaiting response\.\.\.?[ \t]?(\d{3})`) + searchHitsRe = regexp.MustCompile(`found (\d+) matches`) +) + // testSummary extracts a compact pass/fail summary from test-runner output -// (go test / pytest / jest). ok=false when nothing recognizable. +// (go test / pytest / jest / vitest / cargo / TAP). ok=false when nothing +// recognizable — only structured runner patterns produce a verdict. The +// chip carries counts when the runner provides them ("✓ 5 passed · 2 +// skipped") and the go -cover figure when present ("· 82.3% cov"). func testSummary(s string) (summary string, ok bool) { - lower := strings.ToLower(s) - fails := 0 + lineFails, countedFails, passes, suitePasses, skips := 0, 0, 0, 0, 0 + counted := false + covSum, covN := 0.0, 0 var failNames []string + seen := map[string]bool{} + count := func(ln string, suite bool) { + // Doubled summaries (jest reruns) must not inflate counts — but cargo + // prints an identical "test result:" line per package; those are + // real repeats. + if seen[ln] && !strings.HasPrefix(ln, "test result:") { + return + } + seen[ln] = true + if !suite && !countSummaryRe.MatchString(ln) { + return + } + for _, m := range countedRe.FindAllStringSubmatch(ln, -1) { + n, _ := strconv.Atoi(m[1]) + switch m[2] { + case "failed": + countedFails += n + case "passed": + if suite { + suitePasses += n + } else { + passes += n + counted = true + } + default: // skipped, ignored + skips += n + } + } + } for _, ln := range strings.Split(s, "\n") { - // go test: "--- FAIL: TestX" - if name, found := cutPrefixTrim(ln, "--- FAIL: "); found { - fails++ + trimmed := strings.TrimSpace(ln) + // go -cover figure — an addition to an existing pass verdict, never + // one on its own. + if m := coverageRe.FindStringSubmatch(trimmed); m != nil { + if v, err := strconv.ParseFloat(m[1], 64); err == nil { + covSum += v + covN++ + } + } + // go test: "--- FAIL: TestX" (indented for subtests). + if name, found := cutPrefixTrim(trimmed, "--- FAIL: "); found { + lineFails++ if fields := strings.Fields(name); len(fields) > 0 && len(failNames) < 3 { failNames = append(failNames, fields[0]) } continue } - // pytest: "FAILED tests/test_x.py::test_y" - if strings.Contains(ln, "FAILED ") { - fails++ + // go test verbose: "--- PASS: TestY" / "--- SKIP: TestZ". + if strings.HasPrefix(trimmed, "--- PASS: ") { + passes++ continue } - // jest: "✕ test name" / "● test name" - if strings.HasPrefix(strings.TrimSpace(ln), "✕") { - fails++ + if strings.HasPrefix(trimmed, "--- SKIP: ") { + skips++ + continue } + // pytest: "FAILED tests/test_x.py::test_y". + if strings.HasPrefix(trimmed, "FAILED ") { + lineFails++ + continue + } + // jest: "✕ test name". + if strings.HasPrefix(trimmed, "✕") { + lineFails++ + continue + } + lower := strings.ToLower(trimmed) + // Suite-level lines ("Test Suites:" / "Test Files") must not inflate + // the test count when the Tests line is present too. + suite := strings.HasPrefix(lower, "test files") || strings.HasPrefix(lower, "test suites") + // cargo: "test result: ok. 5 passed; 0 failed; ..." — the counts + // carry the verdict; no blanket increment on the ok prefix. + if strings.HasPrefix(lower, "test result:") { + count(lower, false) + continue + } + // go test package verdict, TAP ok, or a counted summary line. + if goPassRe.MatchString(trimmed) || tapPassRe.MatchString(trimmed) { + passes++ + continue + } + count(lower, suite) + } + // Runner summaries restate what the per-test lines already said — take + // the larger count instead of adding both. + fails := lineFails + if countedFails > fails { + fails = countedFails } if fails == 0 { - // A clean run: go test "ok \tpkg", pytest "N passed". - if strings.Contains(lower, "\nok ") || strings.Contains(lower, " passed") || - strings.Contains(lower, "testsuite") { - return "✓ tests pass", true + if passes == 0 && suitePasses > 0 { + passes, counted = suitePasses, true + } + if passes == 0 && skips == 0 { + return "", false + } + chip := "✓ tests pass" + if counted { + chip = fmt.Sprintf("✓ %d passed", passes) + } + if skips > 0 { + chip += fmt.Sprintf(" · %d skipped", skips) + } + if covN > 0 { + avg := math.Round(covSum/float64(covN)*10) / 10 + chip += " · " + strconv.FormatFloat(avg, 'f', -1, 64) + "% cov" } - return "", false + return chip, true } summary = fmt.Sprintf("✗ %d failing", fails) if len(failNames) > 0 { @@ -341,12 +471,24 @@ func stepDetail(name, result string, width int, th theme) []string { } // stepHeadSuffix renders the typed chip a step line gains from its result: -// a diffstat for diffs, a pass/fail summary for test runs. -func stepHeadSuffix(name, result string, th theme) string { +// a diffstat for diffs; a pass/fail summary for test runs; arg-gated git, +// lint, warning, and HTTP hints for shell steps; a hit count for searches. +// At most one chip per step, in that precedence. +func stepHeadSuffix(name, arg, result string, th theme) string { if adds, dels, ok := diffStatOf(result); ok { return th.diffAdd.Render(fmt.Sprintf(" +%d", adds)) + th.diffDel.Render(fmt.Sprintf(" −%d", dels)) } + result = sgrResidueRe.ReplaceAllString(result, "") + if name != "shell" { + // Non-shell steps get exactly one chip: a search hit count. Test + // verdicts stay shell-only — a read_file returning runner text is a + // file read, not a test run. + if isSearchTool(name) { + return hitsChip(result, th) + } + return "" + } if s, ok := testSummary(result); ok { if strings.HasPrefix(s, "✗") { // The step's status icon already flags the failure — the chip @@ -355,5 +497,201 @@ func stepHeadSuffix(name, result string, th theme) string { } return th.stepDone.Render(s) } + for _, chip := range []string{ + gitChip(arg, result, th), + lintChip(arg, result, th), + warnChip(result, th), + httpChip(arg, result, th), + } { + if chip != "" { + return chip + } + } + return "" +} + +// isSearchTool reports whether a tool's hits deserve the hit-count chip — +// same substring matching style as toolGlyph. +func isSearchTool(name string) bool { + n := strings.ToLower(name) + for _, k := range []string{"grep", "search", "glob", "find"} { + if strings.Contains(n, k) { + return true + } + } + return false +} + +// shellWords flattens a shell step's command into words so chip gates can +// check what the step actually ran (multiline scripts included). +func shellWords(arg string) []string { + return strings.Fields(strings.ReplaceAll(arg, "\n", " ")) +} + +func hasWord(words []string, want string) bool { + for _, w := range words { + if w == want { + return true + } + } + return false +} + +// gitChip decorates git commit/push steps with their outcome — the short +// hash plus subject for commits, the branch for pushes. Requires the git +// verb in the command words, so output that merely mentions a hash stays +// chip-free. +func gitChip(arg, result string, th theme) string { + words := shellWords(arg) + if !hasWord(words, "git") { + return "" + } + if hasWord(words, "commit") { + for _, ln := range strings.Split(result, "\n") { + m := commitLineRe.FindStringSubmatch(strings.TrimSpace(ln)) + if m == nil { + continue + } + hash := commitHashRe.FindString(strings.TrimSpace(m[1])) + if hash == "" { + continue + } + if len(hash) > 7 { + hash = hash[:7] + } + chip := "⎇ " + hash + if subj := strings.TrimSpace(m[2]); subj != "" { + chip += " " + truncate(subj, 26) + } + return th.stepDone.Render(chip) + } + } + if hasWord(words, "push") { + branch := "" + for _, ln := range strings.Split(result, "\n") { + if m := gitPushRe.FindStringSubmatch(ln); m != nil { + branch = m[1] + } + if m := gitNewRefRe.FindStringSubmatch(ln); m != nil { + branch = m[1] + } + } + if branch != "" { + return th.stepDone.Render("↑ " + branch) + } + if strings.Contains(result, "Everything up-to-date") { + return th.stepDone.Render("↑ up to date") + } + } + return "" +} + +// lintChip reports the linter outcome: "✓ lint clean" or a red issue +// count. Gated on linter-sounding commands, so ruff's "All checks passed" +// cannot leak into arbitrary output. +func lintChip(arg, result string, th theme) string { + words := shellWords(arg) + linters := []string{"lint", "golangci-lint", "ruff", "eslint", "clippy"} + gate := false + for _, l := range linters { + if hasWord(words, l) { + gate = true + break + } + } + if !gate { + // Word match, not substring: "git commit -m fix-lint" is not a lint + // run. + return "" + } + for _, ln := range strings.Split(result, "\n") { + t := strings.TrimSpace(ln) + if m := lintIssuesRe.FindStringSubmatch(t); m != nil { + if n, _ := strconv.Atoi(m[1]); n == 0 { + return th.stepDone.Render("✓ lint clean") + } + return th.stepErr.Render(m[1] + " issues") + } + if strings.HasPrefix(t, "All checks passed") { + return th.stepDone.Render("✓ lint clean") + } + if m := eslintProblemsRe.FindStringSubmatch(t); m != nil { + if n, _ := strconv.Atoi(m[1]); n == 0 { + return th.stepDone.Render("✓ lint clean") + } + return th.stepErr.Render(m[1] + " issues") + } + } + return "" +} + +// warnChip surfaces compiler warning summaries ("warning: 2 warnings +// emitted", "3 warnings generated") as an amber chip. Only summary lines +// count — individual warning lines are chatter. +func warnChip(result string, th theme) string { + n := 0 + for _, ln := range strings.Split(result, "\n") { + t := strings.TrimSpace(ln) + if m := warnEmittedRe.FindStringSubmatch(t); m != nil { + n, _ = strconv.Atoi(m[1]) + continue + } + if m := warnGeneratedRe.FindStringSubmatch(t); m != nil { + n, _ = strconv.Atoi(m[1]) + } + } + if n > 0 { + chip := "warning" + if n > 1 { + chip = "warnings" + } + return th.badgeWarn.Render(fmt.Sprintf("⚠ %d %s", n, chip)) + } + return "" +} + +// httpChip colors the final HTTP status of a client step: green 2xx, amber +// 3xx, red otherwise. Gated on the client heading the command, so a cat of +// saved headers never gets one. +func httpChip(arg, result string, th theme) string { + head := strings.Fields(strings.SplitN(arg, "\n", 2)[0]) + if len(head) == 0 { + return "" + } + switch head[0] { + case "curl", "wget", "http", "https": + default: + return "" + } + code := "" + for _, ln := range strings.Split(result, "\n") { + if m := httpStatusRe.FindStringSubmatch(ln); m != nil { + code = m[1] + } + if m := wgetStatusRe.FindStringSubmatch(ln); m != nil { + code = m[1] + } + } + if code == "" { + return "" + } + switch code[0] { + case '2': + return th.stepDone.Render("● " + code) + case '3': + return th.badgeWarn.Render("● " + code) + default: + return th.stepErr.Render("● " + code) + } +} + +// hitsChip summarizes search steps: odek's "found N matches" envelope +// becomes a neutral hit count. +func hitsChip(result string, th theme) string { + for _, ln := range strings.Split(result, "\n") { + if m := searchHitsRe.FindStringSubmatch(ln); m != nil { + return th.stepRes.Render(m[1] + " hits") + } + } return "" } diff --git a/internal/tui/renderers_diff_test.go b/internal/tui/renderers_diff_test.go index 8a15c9f..f9c2085 100644 --- a/internal/tui/renderers_diff_test.go +++ b/internal/tui/renderers_diff_test.go @@ -105,7 +105,7 @@ func TestStepDetailPrefersFences(t *testing.T) { func TestStepHeadSuffixCountsFences(t *testing.T) { th := newTheme() - suffix := stepHeadSuffix("edit", "```diff\n+a\n-b\n-c\n```", th) + suffix := stepHeadSuffix("edit", "", "```diff\n+a\n-b\n-c\n```", th) if !strings.Contains(suffix, "+1") || !strings.Contains(suffix, "−2") { t.Errorf("fence diffstat chip = %q, want +1 −2", suffix) } diff --git a/internal/tui/renderers_test.go b/internal/tui/renderers_test.go index 45b79bf..8df5342 100644 --- a/internal/tui/renderers_test.go +++ b/internal/tui/renderers_test.go @@ -76,20 +76,62 @@ func TestRenderJSON(t *testing.T) { } func TestTestSummary(t *testing.T) { - goFail := "=== RUN TestX\n--- FAIL: TestX (0.00s)\nFAIL\nexit status 1" - if s, ok := testSummary(goFail); !ok || s != "✗ 1 failing (TestX)" { - t.Errorf("go fail summary = %q, %v", s, ok) + passes := []struct{ name, out, want string }{ + {"go verbose", "=== RUN TestY\n--- PASS: TestY\nok \texample.com/pkg\t0.5s", "✓ tests pass"}, + {"go ok cached", "ok \texample.com/pkg\t(cached)", "✓ tests pass"}, + {"go ok only", "ok \texample.com/pkg\t1.2s\nok \texample.com/other\t0.3s", "✓ tests pass"}, + {"go coverage", "ok \texample.com/pkg\t0.5s\tcoverage: 82.3% of statements", "✓ tests pass · 82.3% cov"}, + {"go coverage averaged", "ok \ta\t0.5s\tcoverage: 82.0% of statements\n" + + "ok \tb\t0.3s\tcoverage: 84.0% of statements", "✓ tests pass · 83% cov"}, + {"go skips", "=== RUN TestY\n--- PASS: TestY\n--- SKIP: TestS (0.00s)\nok \texample.com/pkg\t0.5s", "✓ tests pass · 1 skipped"}, + {"pytest", "=================================== test session starts ==\n5 passed in 0.12s", "✓ 5 passed"}, + // "1 warning" in the summary line must not pollute the chip. + {"pytest skips", "5 passed, 2 skipped, 1 warning in 0.12s", "✓ 5 passed · 2 skipped"}, + // Suite-level lines ("Test Suites"/"Test Files") never inflate the + // test count. + {"jest", "Test Suites: 1 passed, 1 total\nTests: 3 passed, 3 total", "✓ 3 passed"}, + {"vitest", "Test Files 2 passed (2)\n Tests 5 passed | 3 skipped (8)", "✓ 5 passed · 3 skipped"}, + {"cargo", "running 5 tests\ntest a ... ok\n" + + "test result: ok. 5 passed; 0 failed; 2 ignored; 0 measured", "✓ 5 passed · 2 skipped"}, + {"tap", "1..2\nok 1 - adds numbers\nok 2 - trims input", "✓ tests pass"}, } - goOK := "=== RUN TestY\n--- PASS: TestY\nok \texample.com/pkg\t0.5s" - if s, ok := testSummary(goOK); !ok || s != "✓ tests pass" { - t.Errorf("go pass summary = %q, %v", s, ok) + for _, tc := range passes { + if s, ok := testSummary(tc.out); !ok || s != tc.want { + t.Errorf("%s: summary = %q, %v; want %q", tc.name, s, ok, tc.want) + } + } + fails := []struct{ name, out, want string }{ + {"go", "=== RUN TestX\n--- FAIL: TestX (0.00s)\nFAIL\nexit status 1", "✗ 1 failing (TestX)"}, + // FAILED lines plus the pytest summary line must not double count. + {"pytest", "FAILED tests/x_test.py::test_a\n1 failed, 3 passed in 0.1s", "✗ 1 failing"}, + {"pytest quiet", ".F.\n1 failed, 2 passed in 0.3s", "✗ 1 failing"}, + {"cargo", "test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured", "✗ 2 failing"}, + {"jest", "✕ renders header (5 ms)\nTests: 1 failed, 2 passed, 3 total", "✗ 1 failing"}, + } + for _, tc := range fails { + if s, ok := testSummary(tc.out); !ok || s != tc.want { + t.Errorf("%s: summary = %q, %v; want %q", tc.name, s, ok, tc.want) + } } - pyFail := "FAILED tests/x_test.py::test_a\n1 failed, 3 passed in 0.1s" - if s, ok := testSummary(pyFail); !ok || !strings.Contains(s, "1 failing") { - t.Errorf("pytest summary = %q, %v", s, ok) + // Ordinary output that used to trip the loose substring matcher must + // stay silent: no bare "ok" lines, no prose "passed", no "testsuite", + // and coverage numbers without a pass signal are not a verdict. + noise := []string{ + "Build passed", + "All validation checks passed", + "Deployment passed all gates", + "ok 127 packages found", + "listing testsuite\ntestsuite/ fixtures/", + "everything is ok", + "0 packets passed the filter", + "coverage: 82.3% of statements", + "just some output\nnothing testy", + "", } - if _, ok := testSummary("just some output\nnothing testy"); ok { - t.Error("plain output matched a test summary") + for _, s := range noise { + if got, ok := testSummary(s); ok { + t.Errorf("testSummary(%q) = %q; want no match", s, got) + } } } @@ -123,17 +165,83 @@ func TestStepDetailDispatch(t *testing.T) { } } -// TestStepHeadSuffix verifies the step-line chips: diffstat and test verdict. +// TestStepHeadSuffix verifies the step-line chips: diffstat, test verdict, +// and the arg-gated family (git, lint, warnings, HTTP) plus search hits. func TestStepHeadSuffix(t *testing.T) { th := newTheme() - if got := plain(stepHeadSuffix("diff", diffFixture, th)); got != " +2 −1" { - t.Errorf("diffstat chip = %q", got) + chips := []struct{ name, tool, arg, result, want string }{ + {"diffstat", "diff", "", diffFixture, " +2 −1"}, + {"go pass", "shell", "go test ./...", "--- PASS: TestY\nok \tpkg\t0.5s", "✓ tests pass"}, + {"ordinary", "shell", "ls", "ordinary output", ""}, + {"read_file no verdict", "read_file", "out.txt", "ok \tpkg\t0.5s", ""}, + {"prose pass", "shell", "./deploy.sh", "Build passed\nAll checks passed", ""}, + {"commit", "shell", `git commit -m "fix: stuff"`, + "[main a1b2c3def9] fix: stuff\n 1 file changed, 2 insertions(+)", "⎇ a1b2c3d fix: stuff"}, + {"commit root", "shell", "git commit -m init", "[main (root-commit) 1234567] init", "⎇ 1234567 init"}, + {"commit needs git arg", "shell", "echo hi", "[main a1b2c3d] x", ""}, + {"commit subject capped", "shell", "git commit -m x", + "[main abcdef1234] fix: 12345678901234567890123", "⎇ abcdef1 fix: 12345678901234567890…"}, + {"push", "shell", "git push origin main", + "To github.com:me/x.git\n 7c0a0dc..8cefa19 main -> main", "↑ main"}, + {"push force", "shell", "git push --force origin main", + "+ 7c0a0dc...8cefa19 main -> main", "↑ main"}, + {"push new branch", "shell", "git push -u origin feat/x", + "* [new branch] feat/x -> feat/x", "↑ feat/x"}, + {"push up to date", "shell", "git push", "Everything up-to-date", "↑ up to date"}, + {"push needs git arg", "shell", "echo pushing", " 7c0a0dc..8cefa19 main -> main", ""}, + {"lint clean", "shell", "golangci-lint run", "0 issues.", "✓ lint clean"}, + {"lint issues", "shell", "make lint", "2 issues.", "2 issues"}, + {"lint ruff", "shell", "ruff check .", "All checks passed!", "✓ lint clean"}, + {"lint needs lint arg", "shell", "go build ./...", "0 issues.", ""}, + {"warnings emitted", "shell", "cargo build", "warning: unused variable\nwarning: 2 warnings emitted", "⚠ 2 warnings"}, + {"warnings generated", "shell", "make", "lib.c:3:5: warning: unused var\n3 warnings generated.", "⚠ 3 warnings"}, + {"no zero-warning chip", "shell", "cargo build", "warning: 0 warnings emitted", ""}, + {"http ok", "shell", "curl -s https://api.example.com/health", "HTTP/2 200", "● 200"}, + {"http err", "shell", "curl http://x.dev/api", "HTTP/1.1 404 Not Found", "● 404"}, + {"http 3xx amber", "shell", "curl -L http://x.dev", "HTTP/1.1 302 Found", "● 302"}, + {"http redirect chain", "shell", "curl -L http://x.dev", "HTTP/1.1 301 Moved\nHTTP/2 200", "● 200"}, + {"http wget style", "shell", "wget -qO- http://x.dev", + "--2026-08-31 12:00:00-- http://x.dev/\nHTTP request sent, awaiting response... 200 OK", "● 200"}, + {"http needs client arg", "shell", "cat headers.txt", "HTTP/2 200", ""}, + {"go pass colored", "shell", "go test ./...", "[32mok \texample.com/pkg[0m [33m0.5s[0m", "✓ tests pass"}, + {"pytest colored pass", "shell", "pytest -q", "[32m5 passed[0m in [32m0.12s[0m", "✓ 5 passed"}, + {"pytest colored fail", "shell", "pytest -q", "[31mFAILED tests/x.py::test_a[0m\n[31m1 failed[0m, 3 passed in 0.1s", "1 failing"}, + {"pytest default pass", "shell", "pytest", + "============================================================ 5 passed in 0.12s ==========================" + + "==================", "✓ 5 passed"}, + {"pytest default fail", "shell", "pytest", + "================================= FAILURES ==========================\n======= 1 failed, 2 passed in 0.3s ========" + + "====", "1 failing"}, + {"golangci colon issues", "shell", "golangci-lint run", "2 issues:\n- x.go:1:1: boom", "2 issues"}, + {"prose counts stay silent", "shell", "./validate.sh", "10 files failed validation, 5 passed", ""}, + {"prose counts stay silent 2", "shell", "./validate.sh", "5 passed, 10 failed validation", ""}, + {"lint word gate", "shell", "git commit -m fix-lint", "0 issues.", ""}, + {"warnings prose anchored", "shell", "grep -rn TODO .", "42 warnings generated during the scan", ""}, + {"warnings singular", "shell", "cargo build", "warning: 1 warning emitted", "⚠ 1 warning"}, + {"eslint issues red", "shell", "eslint .", "✖ 2 problems (2 errors, 0 warnings)", "2 issues"}, + {"eslint zero", "shell", "eslint .", "✖ 0 problems", "✓ lint clean"}, + {"jest duplicated summaries", "shell", "npm test", + "Test Suites: 1 passed, 1 total\nTests: 3 passed, 3 total\nTests: 3 passed, 3 total", "✓ 3 passed"}, + {"jest suites only", "shell", "npm test", "Test Suites: 1 passed, 1 total", "✓ 1 passed"}, + {"hits only on search tools", "read_file", "novel.txt", "found 5 matches in the manuscript", ""}, + {"hits", "grep", "needle", "found 3 matches mentioning error", "3 hits"}, + {"hits not on shell", "shell", "grep -rn needle .", "found 3 matches mentioning error", ""}, + {"test beats git", "shell", "git commit -m x", "--- PASS: TestY\nok \tpkg\t0.5s", "✓ tests pass"}, + } + for _, tc := range chips { + if got := plain(stepHeadSuffix(tc.tool, tc.arg, tc.result, th)); got != tc.want { + t.Errorf("%s: chip = %q, want %q", tc.name, got, tc.want) + } + } + // Severity styling: lint issues and 5xx run red, warnings run amber. + if got := stepHeadSuffix("shell", "make lint", "2 issues.", th); got != th.stepErr.Render("2 issues") { + t.Errorf("lint issues style = %q", plain(got)) } - if got := plain(stepHeadSuffix("shell", "--- PASS: TestY\nok \tpkg", th)); got != "✓ tests pass" { - t.Errorf("tests chip = %q", got) + if got := stepHeadSuffix("shell", "cargo build", "warning: 2 warnings emitted", th); got != th.badgeWarn.Render("⚠ 2 warnings") { + t.Errorf("warnings style = %q", plain(got)) } - if got := stepHeadSuffix("shell", "ordinary output", th); got != "" { - t.Errorf("unexpected chip: %q", got) + if got := stepHeadSuffix("shell", "curl http://x", "HTTP/1.1 500", th); got != th.stepErr.Render("● 500") { + t.Errorf("http 5xx style = %q", plain(got)) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index d05f959..c4cd64e 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -781,7 +781,7 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in // deliberately never rendered. right := "" if s.done { - if chip := stepHeadSuffix(s.name, s.result, th); chip != "" { + if chip := stepHeadSuffix(s.name, s.arg, s.result, th); chip != "" { right = chip } }