diff --git a/docs/changes/unreleased/1807-report-first-prose-one-reader.md b/docs/changes/unreleased/1807-report-first-prose-one-reader.md new file mode 100644 index 0000000000..0d0bcce978 --- /dev/null +++ b/docs/changes/unreleased/1807-report-first-prose-one-reader.md @@ -0,0 +1,32 @@ +--- +kind: fixed +title: a report that opens with a code fence quotes its sentence on every surface, not only the landed card +pr: 1807 +surface: [chat, engine] +invalidates: + - >- + #889 was believed to have fixed the three-backticks quote everywhere. It + fixed only the landed card. A report that opened with a code fence still + drew `` ``` `` on the `/tasks` table, the home panels and the task record, + because `taskOutcome` in internal/session read the literal first line while + the card read the first PROSE line. Every surface now reads a report through + one shared reader, internal/reportline, so the fence is skipped once and for + all of them. +--- + +A task that answers with a diff, a command's output or a JSON block opens its +report with the code fence around that answer, and a fence marker says nothing. +The landed card learned to skip it in #889, but the card was not the only path: +`taskOutcome` wrote `TaskIndexEntry.Outcome` — the field the tasks table, the +home panels and the task record all quote — from the literal first line, so the +same work landed with a sentence on its card and three backticks on its row. + +The two readers could not reach each other: `firstProseLine` and the fence +detector it leaned on, `mdFenceOpen`, were private to internal/tui3, and +`taskOutcome` lives in internal/session. The fence-marker detection and the +first-prose-line reading now live in one leaf package, `internal/reportline`, +that both the surface and the engine import. `taskOutcome`, `taskFirstSentence`, +`switcherFirstLine` and the renderer's `mdFenceOpen` all route through it, and a +structural law pins that they do, so a later hand cannot reach for `firstLine` +and put the fence back on a surface. No wording changed: a row that quoted a +sentence still does, and a row with nothing to quote still draws nothing. diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md index 4b0c1d5d1b..5b09f4ebe6 100644 --- a/internal/manual/chat/reading-a-task-page.md +++ b/internal/manual/chat/reading-a-task-page.md @@ -58,12 +58,14 @@ wall-clock time. ## The card quotes three backticks instead of a sentence — a code fence at the top of a task's report -**The quoted line on a landed card is the report's first line that says something**, not its -literal first line. A task that answers with a diff, a command's output or a JSON block -opens its report with the code fence around that answer, and a fence marker — three -backticks or three tildes, with or without a language word like `go` or `diff` after it — is -punctuation rather than a sentence. Blank lines and fence markers are passed over, and the -first line that is neither is what the card quotes. +**The quoted line a task shows is the report's first line that says something**, not its +literal first line, and this holds everywhere a report is quoted: the landed card in the +conversation, the row on the `/tasks` table, the work band on the home page, and the task +record. A task that answers with a diff, a command's output or a JSON block opens its report +with the code fence around that answer, and a fence marker — three backticks or three +tildes, with or without a language word like `go` or `diff` after it — is punctuation rather +than a sentence. Blank lines and fence markers are passed over, and the first line that is +neither is what gets quoted. So a report that is nothing but a fenced block quotes the first line inside the block, which is the answer itself: @@ -77,9 +79,11 @@ A report with no line to quote at all draws no quotation marks — an empty pair card claiming the work said something. The card falls back to its subtitle and, failing that, to `started 14:02` on its own. -Until 2026-09-11 the card took the report's first line literally, so work that answered +Until 2026-09-11 every surface took the report's first line literally, so work that answered inside a fence drew a quoted half of three backticks and nothing else — the one line the -card exists for spent on the punctuation around the answer. +quote exists for spent on the punctuation around the answer. The landed card learned to skip +the fence first; the tasks table, the home panels and the task record quote the sentence +too, because every surface now reads the report through one shared reader. ## Why most of the work is hidden on a task page — the `▸ worked` chips, the caption outline, and `ctrl+e` diff --git a/internal/reportline/law_test.go b/internal/reportline/law_test.go new file mode 100644 index 0000000000..6261aac434 --- /dev/null +++ b/internal/reportline/law_test.go @@ -0,0 +1,126 @@ +package reportline + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// EVERY PATH THAT QUOTES A REPORT'S FIRST LINE ROUTES THROUGH THIS PACKAGE. +// +// The bug (#931) was two readers: the surface skipped the fence through +// firstProseLine while the engine read the literal first line through taskOutcome, +// so the same report drew a sentence on the landed card and three backticks on the +// tasks table. The fix is one reader, and this law is what keeps it one — a future +// hand that reaches for firstLine instead of FirstProse to open a report fails here +// before it can put the fence back on a surface. +// +// The sanctioned readers are reportline.FirstProse itself and tui3's firstProseLine, +// the thin wrapper the surface already calls (and which this law also pins to +// FirstProse). Each named function below must call one of them. +func TestEverySurfaceThatQuotesAReportRoutesThroughFirstProse(t *testing.T) { + _, here, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate repository") + } + root := filepath.Clean(filepath.Join(filepath.Dir(here), "..", "..")) + + // name → whether the function's body calls a sanctioned report reader. + mustRoute := map[string]bool{ + "taskOutcome": false, // internal/session/task_index.go + "taskFirstSentence": false, // internal/tui3/taskstable.go + "switcherFirstLine": false, // internal/tui3/switcher.go + "landedCard": false, // internal/tui3/taskdone.go + } + seen := map[string]bool{} + // firstProseLine is the surface's wrapper; it must itself reach FirstProse. + wrapperReachesFirstProse := false + wrapperSeen := false + + set := token.NewFileSet() + walkErr := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + switch entry.Name() { + case ".git", "bin", "third_party", "testdata": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + file, err := parser.ParseFile(set, path, nil, 0) + if err != nil { + return err + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + if _, watched := mustRoute[fn.Name.Name]; watched { + seen[fn.Name.Name] = true + if bodyCallsAny(fn.Body, "FirstProse", "firstProseLine") { + mustRoute[fn.Name.Name] = true + } + } + if fn.Name.Name == "firstProseLine" { + wrapperSeen = true + if bodyCallsAny(fn.Body, "FirstProse") { + wrapperReachesFirstProse = true + } + } + } + return nil + }) + if walkErr != nil { + t.Fatal(walkErr) + } + + for name, routed := range mustRoute { + if !seen[name] { + t.Errorf("%s was not found in the tree — if it was renamed, teach this law its new name", name) + continue + } + if !routed { + t.Errorf("%s does not route through reportline.FirstProse (or tui3's firstProseLine); a report read with firstLine puts the fence back on the surface (#931)", name) + } + } + if !wrapperSeen { + t.Error("tui3's firstProseLine was not found; it is the surface's sanctioned wrapper over reportline.FirstProse") + } else if !wrapperReachesFirstProse { + t.Error("tui3's firstProseLine must delegate to reportline.FirstProse so there is one reading, not a copied loop (#931)") + } +} + +// bodyCallsAny reports whether body contains a call whose function name — a bare +// identifier or the selector's final name — is one of names. +func bodyCallsAny(body *ast.BlockStmt, names ...string) bool { + allow := make(map[string]bool, len(names)) + for _, name := range names { + allow[name] = true + } + found := false + ast.Inspect(body, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok { + return true + } + switch fun := call.Fun.(type) { + case *ast.SelectorExpr: + found = found || allow[fun.Sel.Name] + case *ast.Ident: + found = found || allow[fun.Name] + } + return true + }) + return found +} diff --git a/internal/reportline/reportline.go b/internal/reportline/reportline.go new file mode 100644 index 0000000000..a4ca3e4599 --- /dev/null +++ b/internal/reportline/reportline.go @@ -0,0 +1,83 @@ +// Package reportline holds the ONE reading of a task report's first prose line, +// and the fence-marker detection that reading rests on. +// +// A task report is the model's own Markdown. A model that has just run a command +// or produced a diff opens with the fence around it rather than with a sentence — +// the fence is not a defect in the report, it is how the answer is spelled. Every +// surface that quotes a report's opening (the landed card, the tasks table, the +// home panels, the switcher) wants the first line that SAYS something, not the +// row of punctuation around the answer. +// +// That reading lived in internal/tui3 as firstProseLine (#889), and the fence +// detector it leaned on, mdFenceOpen, is private to that package. internal/session +// writes the same field through taskOutcome and could not reach either, so it read +// the literal first line and the fence came back on the tasks table and the home +// panels while the card was already correct (#931). Copying the loop into session +// would have been the wrong fix: TWO OPINIONS ABOUT WHAT A FENCE IS DRIFT. So the +// reading and the detection live here, in a leaf package both the surface and the +// engine import, and there is exactly one of each. +package reportline + +import "strings" + +// Fence reports whether line opens a Markdown code fence, returning the delimiter +// run and the info string the way the renderer reads them. The caller passes a +// line already trimmed of leading space: a fence marker says nothing wherever it +// stands, and FirstProse trims before it asks. +// +// This is the renderer's own fence opinion — internal/tui3's mdFenceOpen is a thin +// wrapper over it — so a marker with a language word after it (go, sh, diff, json) +// is a marker here for exactly the reason it is one there. There is no second +// opinion about what a fence is. +func Fence(line string) (delim, info string, ok bool) { + var mark byte + switch { + case strings.HasPrefix(line, "```"): + mark = '`' + case strings.HasPrefix(line, "~~~"): + mark = '~' + default: + return "", "", false + } + n := 0 + for n < len(line) && line[n] == mark { + n++ + } + info = strings.TrimSpace(line[n:]) + // A backtick in a backtick fence's info string is not an info string — + // CommonMark says so, and the line is prose. + if mark == '`' && strings.ContainsRune(info, '`') { + return "", "", false + } + // The language is the first word; the rest is metadata no lexer reads. + if cut := strings.IndexAny(info, " \t"); cut >= 0 { + info = info[:cut] + } + return line[:n], info, true +} + +// FirstProse is the first line of s that says something, trimmed: a blank line +// says nothing, a fence marker says nothing, and the first line that is neither is +// what the work came to. +// +// A REPORT THAT IS NOTHING BUT A FENCED BLOCK RETURNS THE FIRST LINE INSIDE IT, +// which is the point rather than an exception to it: the work's answer IS the +// block's contents, and a reading that stepped over the whole block to look for +// prose underneath would return nothing at all where there was something to say. +// +// A REPORT WITH NO PROSE AT ALL RETURNS "", which hands the row to the surface's +// emptiness law: a card draws its subtitle or its start stamp rather than an empty +// pair of quotation marks claiming the work said something. +func FirstProse(s string) string { + for _, line := range strings.Split(s, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if _, _, fence := Fence(trimmed); fence { + continue + } + return trimmed + } + return "" +} diff --git a/internal/reportline/reportline_test.go b/internal/reportline/reportline_test.go new file mode 100644 index 0000000000..8fe5dda165 --- /dev/null +++ b/internal/reportline/reportline_test.go @@ -0,0 +1,63 @@ +package reportline + +import "testing" + +// THE ONE READING OF A REPORT'S FIRST PROSE LINE. A leading fence is skipped +// whether a language word follows it or not, blank lines are skipped, a report +// that is nothing but a fence returns the first line inside it, and a report with +// no prose at all returns nothing — which is the surface's emptiness law, not this +// reader's. This table is the rule every surface routes through (#931), moved here +// whole from the tui3 card test it began life as (#889). +func TestFirstProseSkipsFencesBlankLinesAndWrappers(t *testing.T) { + for _, tc := range []struct { + name string + report string + want string + }{ + {"a report that opens with prose", "the seven files are written and the suite passes", "the seven files are written and the suite passes"}, + {"a leading fence is skipped", "```\nthe seven files are written and the suite passes\n```", "the seven files are written and the suite passes"}, + {"a fence's language word is skipped too", "```go\npackage main\n```\nthe suite passes", "package main"}, + {"a tilde fence is skipped too", "~~~\nthe suite passes\n~~~", "the suite passes"}, + {"a leading blank line is skipped", "\n\nthe suite passes", "the suite passes"}, + {"blank lines inside a fence are skipped", "```\n\nthe suite passes\n```", "the suite passes"}, + {"a report that is nothing but a fence returns its first line", "```\n7 files changed, 15 insertions(+)\n```", "7 files changed, 15 insertions(+)"}, + {"a closed fence with nothing inside falls on", "```\n```\nthe suite passes", "the suite passes"}, + {"an unclosed fence with nothing yet inside falls on", "```go\n\nthe suite passes", "the suite passes"}, + {"an indented fence marker is still a fence", " ```\nthe suite passes", "the suite passes"}, + {"a marker is skipped wherever it stands, not only in front", "```\n```\n```diff\n- old\n+ new\n```", "- old"}, + {"a backtick in a backtick fence's info string is prose", "```not`a`fence\nthe suite passes", "```not`a`fence"}, + {"an empty report says nothing", "", ""}, + {"a report of blank lines says nothing", "\n \n", ""}, + {"a report of fence markers alone says nothing", "```\n", ""}, + } { + if got := FirstProse(tc.report); got != tc.want { + t.Errorf("%s: FirstProse(%q) = %q, want %q", tc.name, tc.report, got, tc.want) + } + } +} + +// FENCE IS THE RENDERER'S OWN OPINION. A marker with a language word after it is +// still a marker; a backtick fence whose info string carries a backtick is prose, +// per CommonMark. These pin the detector FirstProse rests on. +func TestFenceReadsMarkersTheWayTheRendererDoes(t *testing.T) { + for _, tc := range []struct { + line string + delim string + info string + ok bool + }{ + {"```", "```", "", true}, + {"```go", "```", "go", true}, + {"~~~", "~~~", "", true}, + {"~~~~ diff meta", "~~~~", "diff", true}, + {"````", "````", "", true}, + {"```not`a`fence", "", "", false}, + {"plain prose", "", "", false}, + {"", "", "", false}, + } { + delim, info, ok := Fence(tc.line) + if ok != tc.ok || delim != tc.delim || info != tc.info { + t.Errorf("Fence(%q) = (%q, %q, %v), want (%q, %q, %v)", tc.line, delim, info, ok, tc.delim, tc.info, tc.ok) + } + } +} diff --git a/internal/session/task_index.go b/internal/session/task_index.go index 5d2b40bf0d..d252bab15b 100644 --- a/internal/session/task_index.go +++ b/internal/session/task_index.go @@ -71,6 +71,7 @@ import ( "github.com/Agent-Field/codeaf/internal/delegate" "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/reportline" ) // taskIndexName is the file, in the session directory. One name, shared by @@ -994,10 +995,16 @@ func taskURI(path string) string { return "" } -// taskOutcome is the report's first sentence, capped. The whole report is in -// the transcript this row points at. +// taskOutcome is the report's first prose sentence, capped. It reads through +// reportline.FirstProse (#931), the same reading internal/tui3's landed card +// takes, so a report that opens with a fenced block — a diff, a command's output, +// a JSON result — writes its first real line into this row rather than the three +// backticks around the answer. The whole report is in the transcript this row +// points at. The surfaces this feeds (the tasks table, the home panels, the task +// record) can no longer recover the sentence once the fence has been written here, +// which is why the reading belongs at this source and not downstream. func taskOutcome(report string) string { - line := strings.TrimSpace(firstLine(report)) + line := reportline.FirstProse(report) if len(line) > taskOutcomeLimit { line = strings.TrimSpace(line[:taskOutcomeLimit]) + "…" } diff --git a/internal/session/taskoutcome_fence_test.go b/internal/session/taskoutcome_fence_test.go new file mode 100644 index 0000000000..d9019c07c9 --- /dev/null +++ b/internal/session/taskoutcome_fence_test.go @@ -0,0 +1,28 @@ +package session + +import "testing" + +// A TASK OUTCOME SKIPS A LEADING FENCE AND KEEPS THE FIRST PROSE LINE. This is +// #931 at its source: taskOutcome writes TaskIndexEntry.Outcome, the field the +// tasks table, the home panels and the task record all quote, and a report that +// opens with a fenced block used to write three backticks into it while the +// landed card (fixed in #889) already showed the sentence. The reading now runs +// through reportline.FirstProse, the same door the card takes. +func TestTaskOutcomeSkipsAFence(t *testing.T) { + const sentence = "the seven files are written and the suite passes" + for _, tc := range []struct { + name string + report string + want string + }{ + {"a fenced block in front of the answer", "```\n" + sentence + "\n```", sentence}, + {"a fence with a language word", "```go\n" + sentence + "\n```", sentence}, + {"a report that is nothing but a fenced block keeps its first line", "```diff\n- old\n+ new\n```", "- old"}, + {"a report that opens with prose is unchanged", sentence, sentence}, + {"a report with nothing to say stays empty", "```\n```\n", ""}, + } { + if got := taskOutcome(tc.report); got != tc.want { + t.Errorf("%s: taskOutcome(%q) = %q, want %q", tc.name, tc.report, got, tc.want) + } + } +} diff --git a/internal/tui3/doneoutcomeline_test.go b/internal/tui3/doneoutcomeline_test.go index fe1fd4be59..64250ed07e 100644 --- a/internal/tui3/doneoutcomeline_test.go +++ b/internal/tui3/doneoutcomeline_test.go @@ -6,7 +6,8 @@ package tui3 // is the model's own markdown. A model that opens with a fenced block — a diff, // a command's output, a JSON result — spends that row on the fence marker itself // unless the reading skips it, and `"```"` is the row saying nothing at all -// (#889). These pin every road the reading can take. +// (#889). The exhaustive table of that reading lives in internal/reportline; +// these pin that the card is wired to it. import ( "strings" @@ -14,37 +15,10 @@ import ( "time" ) -// THE FIRST PROSE LINE, OVER THE TABLE. A leading fence is skipped whether a -// language word follows it or not, blank lines are skipped, a report that is -// nothing but a fence quotes the first line inside it, and a report with no -// prose at all returns nothing — which is the emptiness law's case, not this -// reader's. -func TestFirstProseLineSkipsFencesBlankLinesAndWrappers(t *testing.T) { - for _, tc := range []struct { - name string - report string - want string - }{ - {"a report that opens with prose", "the seven files are written and the suite passes", "the seven files are written and the suite passes"}, - {"a leading fence is skipped", "```\nthe seven files are written and the suite passes\n```", "the seven files are written and the suite passes"}, - {"a fence's language word is skipped too", "```go\npackage main\n```\nthe suite passes", "package main"}, - {"a tilde fence is skipped too", "~~~\nthe suite passes\n~~~", "the suite passes"}, - {"a leading blank line is skipped", "\n\nthe suite passes", "the suite passes"}, - {"blank lines inside a fence are skipped", "```\n\nthe suite passes\n```", "the suite passes"}, - {"a report that is nothing but a fence quotes its first line", "```\n7 files changed, 15 insertions(+)\n```", "7 files changed, 15 insertions(+)"}, - {"a closed fence with nothing inside falls on", "```\n```\nthe suite passes", "the suite passes"}, - {"an unclosed fence with nothing yet inside falls on", "```go\n\nthe suite passes", "the suite passes"}, - {"an indented fence marker is still a fence", " ```\nthe suite passes", "the suite passes"}, - {"a marker is skipped wherever it stands, not only in front", "```\n```\n```diff\n- old\n+ new\n```", "- old"}, - {"an empty report says nothing", "", ""}, - {"a report of blank lines says nothing", "\n \n", ""}, - {"a report of fence markers alone says nothing", "```\n", ""}, - } { - if got := firstProseLine(tc.report); got != tc.want { - t.Errorf("%s: firstProseLine(%q) = %q, want %q", tc.name, tc.report, got, tc.want) - } - } -} +// THE FIRST PROSE LINE'S RULE — a leading fence is skipped, blank lines are +// skipped, a fence-only report quotes the line inside it, an empty report says +// nothing — is pinned once in internal/reportline, which firstProseLine now wraps. +// The two cards below prove the surface is wired to that reading. // A DONE CARD SKIPS A FENCE AND QUOTES THE FIRST PROSE LINE, which is #889 // whole: a quick task lands with a fenced block in front of its answer and the diff --git a/internal/tui3/markdown.go b/internal/tui3/markdown.go index e2e2365191..eaae811f39 100644 --- a/internal/tui3/markdown.go +++ b/internal/tui3/markdown.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/x/ansi" + "github.com/Agent-Field/codeaf/internal/reportline" "github.com/Agent-Field/codeaf/internal/tui2/prose" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -445,32 +446,11 @@ func mdSegments(text string) []mdSegment { } // mdFenceOpen reads an opening fence at column zero, returning its delimiter run -// and its info string. +// and its info string. The reading itself lives in reportline.Fence, the one +// fence opinion the engine's report readers share with this renderer (#931), so +// a marker is a marker here for exactly the reason it is one there. func mdFenceOpen(line string) (delim, info string, ok bool) { - var mark byte - switch { - case strings.HasPrefix(line, "```"): - mark = '`' - case strings.HasPrefix(line, "~~~"): - mark = '~' - default: - return "", "", false - } - n := 0 - for n < len(line) && line[n] == mark { - n++ - } - info = strings.TrimSpace(line[n:]) - // A backtick in a backtick fence's info string is not an info string — - // CommonMark says so, and the line is prose. - if mark == '`' && strings.ContainsRune(info, '`') { - return "", "", false - } - // The language is the first word; the rest is metadata no lexer reads. - if cut := strings.IndexAny(info, " \t"); cut >= 0 { - info = info[:cut] - } - return line[:n], info, true + return reportline.Fence(line) } // mdFenceClose reports whether line closes a fence opened by delim: the same diff --git a/internal/tui3/render.go b/internal/tui3/render.go index aa1d85e657..14702817cf 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/reportline" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -4562,45 +4563,11 @@ func firstLine(s string) string { } // firstProseLine is the first line of a report that says something, and it is -// what the quoted half of a card is built from. -// -// A REPORT IS THE MODEL'S OWN MARKDOWN, and a model that has just run a command -// or produced a diff opens with the fence around it rather than with a sentence -// — the fence is not a defect in the report, it is how the answer is spelled. -// The literal first line of such a report is ``` or ~~~, which is a row of -// punctuation and says nothing at all; quoting it spends the one line the card -// exists to draw on the wrapper around the answer. -// -// SO THE WHOLE LAW IS ONE SENTENCE: a blank line says nothing, a fence marker -// says nothing, and the first line that is neither is what the work came to. -// The markers are read by [mdFenceOpen], the same door the renderer reads them -// through, so a marker with a language word after it — go, sh, diff, json — -// is a marker here for exactly the reason it is one there: a line this surface -// would DRAW as prose is quoted as prose, and there is no second opinion about -// what a fence is. -// -// THAT MEANS A REPORT THAT IS NOTHING BUT A FENCED BLOCK QUOTES THE FIRST LINE -// INSIDE IT, which is the point rather than an exception to it: the work's -// answer IS the block's contents, and a reading that stepped over the whole -// block to look for prose underneath would quote nothing at all — the emptiness -// law doing the opposite of its job, going silent where there was something to -// say. -// -// AND A REPORT WITH NO PROSE AT ALL RETURNS NOTHING, which hands the row to the -// emptiness law where it does belong: the card falls back to its subtitle and, -// failing that, draws the start stamp alone rather than an empty pair of -// quotation marks claiming the work said something ([app.doneUnder] does that -// already, and nothing here has to learn it twice). +// what the quoted half of a card is built from. It is the surface's wrapper over +// reportline.FirstProse — the one reading every surface and the engine share, so +// a report that opens with a fence is quoted by its first sentence here and on the +// tasks table and the home panels alike (#931), with no second opinion about what +// a fence is. func firstProseLine(s string) string { - for _, line := range strings.Split(s, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - if _, _, fence := mdFenceOpen(trimmed); fence { - continue - } - return trimmed - } - return "" + return reportline.FirstProse(s) } diff --git a/internal/tui3/switcher.go b/internal/tui3/switcher.go index e830b7a114..7e46127883 100644 --- a/internal/tui3/switcher.go +++ b/internal/tui3/switcher.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/x/ansi" + "github.com/Agent-Field/codeaf/internal/reportline" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/standing" ) @@ -383,12 +384,14 @@ func switcherStandingNote(view StandingItemView) string { // the word back off, because there the panel's own heading already says it. const switcherAsksWord = "asks: " +// switcherFirstLine is the first line of a text that says something — a question, +// a reason, an activity note, or a task's outcome. It reads through +// reportline.FirstProse (#931), so a report that opens with a fenced block gives +// its first prose line rather than the row of backticks around it, the same +// reading the landed card and the tasks table take; a leading fence marker says +// nothing in any of these contexts. func switcherFirstLine(s string) string { - s = strings.TrimSpace(s) - if at := strings.IndexByte(s, '\n'); at >= 0 { - s = s[:at] - } - return strings.TrimSpace(s) + return reportline.FirstProse(s) } func switcherPlural(n int, one, many string) string { diff --git a/internal/tui3/taskstable.go b/internal/tui3/taskstable.go index 802f75277d..3dde9bffcf 100644 --- a/internal/tui3/taskstable.go +++ b/internal/tui3/taskstable.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/x/ansi" + "github.com/Agent-Field/codeaf/internal/reportline" "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) @@ -354,9 +355,12 @@ func tasksReasonLine(item tasksItem, width int, pal palette) string { // taskFirstSentence is the opening sentence of what a landing wrote down, which // is as much of a report as a single line can honestly carry. A report with no -// sentence end in it is taken whole and left to the fitter. +// sentence end in it is taken whole and left to the fitter. It reads the report's +// first PROSE line through reportline.FirstProse (#931), so a report that opens +// with a fenced block gives its sentence rather than the row of backticks around +// it, the same reading the landed card takes. func taskFirstSentence(report string) string { - report = strings.TrimSpace(strings.SplitN(strings.TrimSpace(report), "\n", 2)[0]) + report = reportline.FirstProse(report) for at, r := range report { if r != '.' && r != '!' && r != '?' { continue