diff --git a/docs/changes/unreleased/930-done-card-first-prose-line.md b/docs/changes/unreleased/930-done-card-first-prose-line.md new file mode 100644 index 0000000000..053c23e513 --- /dev/null +++ b/docs/changes/unreleased/930-done-card-first-prose-line.md @@ -0,0 +1,28 @@ +--- +kind: fixed +title: a task card quotes the report's first line that says something, not its literal first line +pr: 930 +surface: [chat] +invalidates: + - "The landed card's quoted sentence was the report's literal first line (`firstLine(node.report)` in `internal/tui3/taskdone.go`), so a report that opened with a code fence drew \"```\" as its outcome. It is the first line that says something now: leading blank lines and fence markers — ``` and ~~~, with or without a language word — are skipped, a report that is nothing but a fenced block quotes the first line inside it, and a report with no prose at all draws the stamp alone rather than empty quotation marks. `internal/tui3`'s `firstProseLine` is the one reader, and every road that fills a card's quoted half goes through it." + - "The card's row was believed to quote `session.TaskNotice.Report` verbatim from its first byte. It never did anything so simple: the quote is a reading of the report, and the reading now knows what a fence is." +--- + +A quick task landed on 2026-09-11 (`dev@333acc67d`) whose report opened with a +fence around its answer, and the card drew `"```" · started 10:45 · ctrl+o +output` — the one row that exists to carry the work's own sentence, spent on the +punctuation around it. + +The reports are the model's own markdown and a fence in front of an answer is +not a defect in them: anything that reports a diff, a command's output or a JSON +result is *correctly* fenced. The defect was the surface reading the first line +literally, which is a reading that only works for prose. + +The quoted half is now the first line of the report that says something — +`firstProseLine` in `internal/tui3/render.go`, which passes over a blank line and +a fence marker alike and returns the first line that is neither. It reads a +marker through `mdFenceOpen`, the same door the renderer reads one through, so +the surface has one opinion about what a fence is rather than two. A report with +no line to quote keeps the emptiness law it always had: the row falls back to the +subtitle and, failing that, draws the start stamp alone rather than an empty pair +of quotation marks. diff --git a/internal/manual/chat/reading-a-task-page.md b/internal/manual/chat/reading-a-task-page.md index c14f1fb7d5..23a4b35535 100644 --- a/internal/manual/chat/reading-a-task-page.md +++ b/internal/manual/chat/reading-a-task-page.md @@ -42,6 +42,31 @@ while the completion card omits the entire `started 14:02` segment. A shorter or duration leaves the header figure out too. The duration is never used to invent a 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. + +So a report that is nothing but a fenced block quotes the first line inside the block, which +is the answer itself: + +``` +✓ ◆ Fix nil-map crash · done · 4m12s · 3 files + "the guard is in and the regression test passes" · started 14:02 · ctrl+o output +``` + +A report with no line to quote at all draws no quotation marks — an empty pair would be the +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 +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. + ## Why most of the work is hidden on a task page — the `▸ worked` chips, the caption outline, and `ctrl+e` **This is the answer to "my task page is hiding most of the work", "where did the tool diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index b9921830f7..91d2903c36 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -1776,6 +1776,15 @@ the task's row on the roster. The head is what happened. The muted line under it is what came of it, in the task's own first sentence, quoted because they are its words and not aforge's. +**The quoted sentence is the first line of the report that says something**, not its +literal first line. Work that ran a command, produced a diff or answered in JSON often +opens its report with the code fence around that — the fence is how the answer is spelled, +not a sentence — so a fence marker, three backticks or three tildes with or without a +language word after it, is passed over the way a blank line is. The first line that is +neither is what the card quotes, which for a report that is nothing but a fenced block is +the first line inside it. A report with no line to quote draws no quotation marks at all, +only the start stamp. + **Every fact on the head is joined by ` · `, the state word included.** It used to read `done 4m12s`, with the state and the clock fused into one phrase while `3 files` beside them was properly separated — so on a card asking for a hand, the word and the clock ran diff --git a/internal/tui3/doneoutcomeline_test.go b/internal/tui3/doneoutcomeline_test.go new file mode 100644 index 0000000000..fe1fd4be59 --- /dev/null +++ b/internal/tui3/doneoutcomeline_test.go @@ -0,0 +1,86 @@ +package tui3 + +// ── THE QUOTED HALF OF A LANDED CARD ──────────────────────────────────────── +// +// The card's second row quotes what the work came to, and the report it quotes +// 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. + +import ( + "strings" + "testing" + "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) + } + } +} + +// 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 +// row is spent on the answer rather than on the punctuation around it. +func TestADoneCardSkipsAFenceAndQuotesTheFirstProseLine(t *testing.T) { + a, _, _ := taskApp(t) + began := a.now().Add(-10 * time.Minute) + card := &taskDone{ + title: "Fix nil-map crash", + outcome: firstProseLine("```\nthe seven files are written and the suite passes\n```"), + started: began, + } + under := plain(a.doneUnder(card, 120)) + if !strings.Contains(under, `"the seven files are written and the suite passes"`) { + t.Fatalf("the card's second row reads\n\t%q\nand it should quote the first prose line, not the fence:\n\t%q", under, `"the seven files are written and the suite passes"`) + } + if strings.Contains(under, "```") { + t.Fatalf("the card's second row still quotes a fence marker:\n\t%q", under) + } + if !strings.Contains(under, "started "+began.Format("15:04")) { + t.Fatalf("the card's second row is missing its start stamp:\n\t%q", under) + } +} + +// A DONE CARD THAT LANDS WITH NO PROSE TO QUOTE DRAWS THE TAIL ALONE. The +// emptiness law owns this case — no empty pair of quotation marks claiming the +// work said something, and no fence marker standing in for a sentence either. +func TestADoneCardWithNoProseDrawsNoEmptyQuotes(t *testing.T) { + a, _, _ := taskApp(t) + began := a.now().Add(-10 * time.Minute) + card := &taskDone{title: "Fix nil-map crash", outcome: firstProseLine("```\n"), started: began} + under := plain(a.doneUnder(card, 120)) + if strings.Contains(under, `""`) { + t.Fatalf("a card with nothing to quote drew an empty pair of quotes:\n\t%q", under) + } + if !strings.Contains(under, "started "+began.Format("15:04")) { + t.Fatalf("the card's second row lost its start stamp:\n\t%q", under) + } +} diff --git a/internal/tui3/render.go b/internal/tui3/render.go index dd2fb03dff..44f5baae54 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -4321,3 +4321,47 @@ func firstLine(s string) string { } return s } + +// 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). +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 "" +} diff --git a/internal/tui3/taskdone.go b/internal/tui3/taskdone.go index 0db983a8d5..a6c2edeaa5 100644 --- a/internal/tui3/taskdone.go +++ b/internal/tui3/taskdone.go @@ -231,7 +231,7 @@ func (a *app) landedCard(node *taskNode) { span: node.elapsed, started: node.spawnedAt(), landed: landed, - outcome: strings.TrimSpace(firstLine(node.report)), + outcome: firstProseLine(node.report), report: strings.TrimSpace(node.report), result: strings.TrimSpace(node.produced), resultWhole: strings.TrimSpace(node.producedWhole), @@ -762,8 +762,9 @@ func (card *taskDone) hasDetail() bool { // account to draw. This is every ordinary two-line landing, and it is // byte-for-byte what this card drew before the split existed. // - It did not: the report LEADS with the sentence saying so (the engine -// composes it that way, and [taskDone.outcome] is already that first line), -// so that sentence is the account and the rest of the report is the answer. +// composes it that way, and [taskDone.outcome] is already that first line +// that says something), so that sentence is the account and the rest of the +// report is the answer. // // Nothing here reads the wording of a report. The question asked is the card's // own settled state, which is the same rule internal/session's