diff --git a/docs/changes/unreleased/895-tasks-row-says-its-state-once.md b/docs/changes/unreleased/895-tasks-row-says-its-state-once.md new file mode 100644 index 0000000000..c50550c5ee --- /dev/null +++ b/docs/changes/unreleased/895-tasks-row-says-its-state-once.md @@ -0,0 +1,22 @@ +--- +kind: fixed +title: a row of the tasks place says its state once, and a root names its folder only where that is news +pr: 895 +surface: [chat, engine] +invalidates: + - "A row of the tasks place used to carry the record's own outcome sentence — `2 files · Annual is the default and the monthly price stays visible beside it.` It does not any more. What a row carries is what the work touched, then its state and the reason behind it (`your call · nobody could check it`, `incomplete · lost the connection`, `stopped`), then its cost. The landing's report sentence is on the record card, one `enter` away." + - "`tasksMiddle` (internal/tui3/tasksplace.go) used to compose the state word and its reason itself, three different ways, and `tasksNote` spelled a fourth state word for a row nobody is behind. There is one join now and it is the engine's: `session.TaskStatus.RowWord()`. `tasksNote` says only which window the work is in." + - "`checkerStalled` (internal/session/task_audit.go) used to print the abandoned call's bound with `time.Duration.String`, so a landing read `one call ran 29.24078975s without answering and was abandoned`. It is `taskSpanWord` now — `29s`. `checkerRanOut` still spells its window with `String()`, because that figure is a configured ceiling rather than a measured elapsed." + - "The state word moved to THIRD in the tasks row's ranked tail, ahead of the age and ahead of the conversation the work came out of. A narrow frame now gives up the age and the chat's name before it gives up `incomplete` or `your call`; before this it gave up the word first, so at eighty-five cells a row nothing was running named its conversation and hid the fact that nobody was behind it." + - "A finished row on the tasks place now shows its cost at every width down to about seventy cells. It used not to: the spend is drawn only when every fact ahead of it fits whole (`tasksSpendEarnsCells`), and the fact ahead of it used to be a seventy-seven-cell outcome sentence that fitted nowhere. With the sentence gone the figure earns its cells, which is the rule working rather than a new one." + - "A conversation root on the tasks place used to name any folder whose word was not the chat's own title, so a chat opened in a home directory wore a lone `~` at the right of its name and every row of the folder you were already in wore the same project word. Both were retired by docs/design/home-mission-control/DESIGN.md §1 and are now drawn as nothing. The rule lives in internal/tui3/projecttag.go as `chatProjectWord`, and home's `chatProjectTag` delegates to it." +--- + +Three defects on one screenshot, and every one of them was a single fact with +two writers. `internal/session` grades a node somebody stopped with the outcome +`stopped`, and the page hung that outcome off its own state word, so the row read +`stopped · stopped` — one reading said twice because each writer believed it was +the one saying it. The join already existed: `session.TaskStatus.RowWord()` puts +the word and its reason together and drops a reason that merely restates the +word, and it is what the column beside a conversation has always read. The page +asks it now instead of composing the pair itself. diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index d56fdef76b..b35ceeb4f5 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -4908,9 +4908,22 @@ Pressing `x` on a run that has already finished does nothing but say so. The **tasks** place lists main chats and their nested work across projects, grouped by what you do next: `your call`, `running`, `waiting`, `finished today`, then `earlier` — where `waiting` is admitted work nothing is doing, drawn with no age on it, and `finished today` -is everything that ended today however it ended. The main chat names its project once. Child rows can include -activity, age from the landing time their own records carry, and last their -measured cost. The row's kind is not drawn. Zero or unknown cost is left +is everything that ended today however it ended. + +**A main chat's row names its folder only where that folder is news.** A conversation in the +folder this window is already sitting in wears no tag — the tag would be the same word on +every row — and neither does one whose workspace is your home directory or a scratch folder +at the top of `/tmp`. Another project's conversation keeps its name. The **projects** panel on +home still lists all three, as the paths they are. + +**A row of work says its state once.** What a child row can carry is what it touched (`2 +files`), then its state and the reason behind it — `your call · nobody could check it`, +`incomplete · lost the connection`, `stopped`, `done`, `working` — and last its measured +cost. A row whose state needs no reason says the bare word; a row that stopped says +`stopped` and not `stopped · stopped`, which is what it read while the page composed that +pair itself instead of taking the engine's own spelling of it. The landing's own report +sentence is **not** on the row: it is on the task's record, one keypress away through +`enter`. The row's kind is not drawn. Zero or unknown cost is left blank, and so is an age whose older record never carried that landing time. A section with nothing in it is absent. diff --git a/internal/session/task_audit.go b/internal/session/task_audit.go index 23828fe4c1..001f121629 100644 --- a/internal/session/task_audit.go +++ b/internal/session/task_audit.go @@ -735,8 +735,20 @@ func checkerRanOut(window time.Duration) string { // the retry that follows it answers, and the verdict it reaches is what lands. // What keeps it here is the run where the retry does not answer either, where // this is the only account there is of where five minutes went. +// AND THE BOUND IS SPELLED THE WAY EVERY OTHER MEASURED ELAPSED TIME IN THIS +// PACKAGE IS ([taskSpanWord]). [time.Duration.String] is the engine talking to +// itself: it landed `one call ran 29.24078975s without answering` on a person's +// screen, eight decimal places of a figure nobody can act on, and the +// nanoseconds were never a fact about the run — they are the clock's resolution. +// +// [checkerRanOut] BESIDE IT STILL SAYS `5m0s` AND THAT IS NOT AN OVERSIGHT. Its +// figure is the window somebody CONFIGURED — a round number a person chose, in +// the units they chose it in — and `5m 0s` would be an elapsed-time spelling put +// on a ceiling. The two sentences can land on one card and say a duration two +// ways because they are saying two different kinds of thing; what they may never +// do is either of them in nanoseconds. func checkerStalled(bound time.Duration) string { - return "one call ran " + bound.String() + " without answering and was abandoned" + return "one call ran " + taskSpanWord(bound) + " without answering and was abandoned" } // checkerWindowClosed is what a person reads BESIDE A CALL'S OWN ACCOUNT when diff --git a/internal/session/task_audit_words_test.go b/internal/session/task_audit_words_test.go new file mode 100644 index 0000000000..330cbe08c7 --- /dev/null +++ b/internal/session/task_audit_words_test.go @@ -0,0 +1,32 @@ +package session + +import ( + "regexp" + "strings" + "testing" + "time" +) + +// A DURATION A PERSON READS IS SPELLED IN THE UNIT THEY ARE IN. +// +// [time.Duration.String] is the engine talking to itself. It put `one call ran +// 29.24078975s without answering and was abandoned` on a landing a person read +// — eight decimal places of a figure nobody can act on, and the nanoseconds were +// never a fact about the run, only the resolution of the clock that measured it. +// [taskSpanWord] is this package's one spelling of how long something took. +func TestAnAbandonedCallIsSaidInWholeSeconds(t *testing.T) { + raw := regexp.MustCompile(`\d+\.\d+s`) + for _, bound := range []time.Duration{ + 29240789750 * time.Nanosecond, + 2*time.Minute + 500*time.Millisecond, + 90 * time.Minute, + } { + said := checkerStalled(bound) + if raw.MatchString(said) { + t.Fatalf("the landing reads\n %s\nand a person cannot act on the decimals", said) + } + if want := taskSpanWord(bound); !strings.Contains(said, want) { + t.Fatalf("the landing reads\n %s\nwant the one spelling of how long it took, %q", said, want) + } + } +} diff --git a/internal/tui3/homepanel_recent.go b/internal/tui3/homepanel_recent.go index fde530988d..f9aee23322 100644 --- a/internal/tui3/homepanel_recent.go +++ b/internal/tui3/homepanel_recent.go @@ -93,11 +93,12 @@ func recentCell(row switcherRow, in *homeGridInput) *homeCell { // and a scratch folder at the top of the temporary directory, whose word is a // name somebody made up for a minute (DESIGN §1, "what is retired"). The // projects panel still lists both, as the paths they are. +// The rule itself is [chatProjectWord] (projecttag.go), because the tasks place +// asks the same question about the same conversations. This panel has already +// settled the own-folder half of it — a row of this window's own bucket never +// reaches here ([recentCell]) — so it hands no folder to compare. func chatProjectTag(row switcherRow, tilde string) string { - if row.project == "~" || homeScratchFolder(row.session.Workspace, tilde) { - return "" - } - return row.project + return chatProjectWord(chatFolder{project: row.project, workspace: row.session.Workspace}, "", tilde) } // homeScratchRoots are the directories a throwaway folder is made at the top diff --git a/internal/tui3/place_tasks.go b/internal/tui3/place_tasks.go index ff465830ee..09bf986b4c 100644 --- a/internal/tui3/place_tasks.go +++ b/internal/tui3/place_tasks.go @@ -354,7 +354,7 @@ const taskSheetDays = 14 // that could ask it at paint time. func (a *app) taskSheetMine() tasksMine { rows := a.taskSheetOwnRows() - mine := tasksMine{row: a.taskSheetSelfRow(), rows: make([]tasksMineRow, 0, len(rows))} + mine := tasksMine{row: a.taskSheetSelfRow(), tilde: a.tilde, rows: make([]tasksMineRow, 0, len(rows))} for _, entry := range rows { row := tasksMineRow{entry: entry, runs: a.recordRuns(&entry)} if node := a.taskSheetNodeFor(&entry); node != nil { diff --git a/internal/tui3/projecttag.go b/internal/tui3/projecttag.go new file mode 100644 index 0000000000..30d92ba983 --- /dev/null +++ b/internal/tui3/projecttag.go @@ -0,0 +1,71 @@ +package tui3 + +import ( + "path/filepath" + "strings" +) + +// projecttag.go holds THE ONE RULE about whether a row naming a conversation +// also names the folder that conversation works in. +// +// The mission-control ruling retired two of the three answers this surface used +// to give (docs/design/home-mission-control/DESIGN.md §1, "What is retired"): +// a project tag on a row in THIS WINDOW'S OWN FOLDER, where the tag is the same +// word on every row and says nothing that tells one row from another; and `~` +// as a project name, because the home directory is where a person stands rather +// than something they are working on. A scratch folder at the top of the +// temporary directory goes with them — its word is a name somebody made up for +// a minute. The projects panel still lists all three, as the paths they are. +// +// IT IS HERE AND NOT ON ONE OF THE TWO PAGES because both of them draw the same +// object. Home's list of conversations asks it ([chatProjectTag]) and the tasks +// place asks it of every conversation root ([tasksChatRow]); the tasks place +// used to ask nobody at all, so a chat in the home directory arrived on it +// wearing a lone `~` at the right of its name. + +// chatFolder is the three facts the rule needs about one conversation: what its +// folder is CALLED, the tools root recorded for the conversation, and the +// project directory it belongs to. +// +// IT IS A STRUCT AND NOT THREE STRINGS IN A ROW. Every one of them is a path or +// a path's name, so a positional signature is one a caller can transpose and +// still compile — and the failure that buys is silent, a tag drawn for the wrong +// reason on a row nobody looks at twice. +type chatFolder struct { + project string + workspace string + dir string +} + +// chatProjectWord is that rule: the folder's word, and NOTHING where the folder +// is not a project. `here` is the folder the window drawing the row is itself +// standing in, and "" where the caller has no such folder to compare against — +// which is the honest answer for a surface that draws several projects at once, +// and the answer a window whose own conversation the world scan has not met yet +// falls back to ([readTasks] says so where it reads the folder). +func chatProjectWord(chat chatFolder, here, tilde string) string { + project := strings.TrimSpace(chat.project) + switch { + case project == "" || project == "~": + return "" + case homeScratchFolder(chat.workspace, tilde): + return "" + // BOTH SIDES ARE CLEANED, and not because either is known to be ragged: the + // check above this one cleans its paths, [session.Project]'s own path is a + // recorded workspace or a launch directory with no cleaning applied to + // either, and a rule that is byte-exact on one line and forgiving on the next + // is a rule nobody can predict. + case here != "" && sameFolder(chat.dir, here): + return "" + } + return project +} + +// sameFolder is that comparison, spelled once. +func sameFolder(a, b string) bool { + a, b = strings.TrimSpace(a), strings.TrimSpace(b) + if a == "" || b == "" { + return false + } + return filepath.Clean(a) == filepath.Clean(b) +} diff --git a/internal/tui3/taskending.go b/internal/tui3/taskending.go index 153a1e5c59..d90e29eea2 100644 --- a/internal/tui3/taskending.go +++ b/internal/tui3/taskending.go @@ -88,11 +88,3 @@ func halted(ending session.TaskEnding) bool { // docs/design/task-states/DESIGN.md). The same cell in both glyph tiers, // because ! is already a character a screen with no Unicode has. const glyphHalted = "!" - -// refused says the task reached a check and the check did not accept its claim. -// The engine still settles that node as failed so dependencies and delivery do -// not advance; the surface calls the person's state incomplete because the -// report names work still to do rather than a runtime fault. -func refused(ending session.TaskEnding) bool { - return ending == session.TaskEndingRefused -} diff --git a/internal/tui3/taskphone_test.go b/internal/tui3/taskphone_test.go index d86aa7edbe..0773cff44f 100644 --- a/internal/tui3/taskphone_test.go +++ b/internal/tui3/taskphone_test.go @@ -116,7 +116,11 @@ func TestThePhoneRosterRowsAreTwoLineCards(t *testing.T) { if strings.Contains(line, "Sweep the call sites") { name = i } - if strings.Contains(line, "it came home clean") { + // The tail is what the work came to and how long ago: the row's own + // state word and its age ([tasksCardTail]). It used to be the record's + // raw outcome sentence, which on every graded row was the state word + // again in different clothes ([tasksMiddle] says why that went). + if strings.Contains(line, "done · 3h") { tail = i } } @@ -179,7 +183,10 @@ func TestThePhoneRosterScrollsToACardPastTheFold(t *testing.T) { // AND IT IS DRAWN WHOLE. A card is two lines, so a window that counted rows // rather than lines would leave the tail of the one a thumb scrolled to // clipped at the fold. - if head+1 >= len(lines) || !strings.Contains(lines[head+1], "it came home clean") { + // IT ASSERTS THE WHOLE TAIL AND NOT A WORD OF IT. A clipped tail still + // carries its first segment, so a test that looked for one would pass on + // exactly the frame this test exists to fail. + if tail := tasksCardTail(last, a.now()); head+1 >= len(lines) || tail == "" || !strings.Contains(lines[head+1], tail) { t.Fatalf("the cursor's card is clipped at the fold:\n%s", joined) } for i, line := range lines { diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 0555fbcb77..fc0e701de5 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -145,6 +145,11 @@ type tasksMine struct { // across the desk — and the difference decides whether the row's door is a // switch or a second view onto the engine. here map[string]bool + // tilde is this machine's home directory, which is what tells a folder + // somebody WORKS in from the one they stand in ([chatProjectWord]). It is + // read once at boot and handed in like every other fact, because the reading + // may not ask the surface a question. + tilde string } // tasksMineRow is one of those rows: the work, and whether it is happening. @@ -203,6 +208,12 @@ type tasksReading struct { // it on: a query. A row that matched and is behind a fold is a row the query // appears not to have found ([tasksPlace.filtered]). unfolded bool + // folder is the project THIS WINDOW is standing in and tilde this machine's + // home directory. They are the two facts [chatProjectWord] needs to decide + // whether a conversation root also names its folder — a tag on every row of + // the folder a person is already in tells one row from no other. + folder string + tilde string } // tasksKey is what identifies ONE piece of work across every authority: the @@ -224,7 +235,7 @@ func tasksKeyOf(entry session.TaskIndexEntry) tasksKey { // graph, then the other windows — which are reading a presence file written // seconds ago and are the only authority for work that has not landed. func readTasks(world session.World, mine tasksMine, win session.UsageWindow, seen, now time.Time) tasksReading { - r := tasksReading{win: win.Normalized(), seen: seen, now: now} + r := tasksReading{win: win.Normalized(), seen: seen, now: now, tilde: mine.tilde} // order keeps the pass stable: a map alone would re-order the page on every // frame it was rebuilt, and the sections below are drawn in the order the // rows arrived within each one. @@ -287,6 +298,23 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, see // and both of them under the chat that asked for the work. visible := make([]tasksItem, 0, len(order)) r.chats = tasksConversationRows(world, mine, r.win, now) + // AND THE WINDOW'S OWN FOLDER IS READ OFF ITS OWN CONVERSATION ROW, which is + // where the two authorities have already met: this window knows what it is + // called and the world scan knows which bucket that conversation belongs to, + // and [tasksConversationRows] merges the pair. Asking [tasksMine.row] + // directly gets the half that has no folder on it, which is how every row of + // the folder a person is sitting in went on wearing its name. + // + // AND A WINDOW THE SCAN HAS NOT MET YET HAS NO FOLDER, which leaves every row + // wearing its project tag until it has. That is the right way for this to + // fail — a tag too many is a fact that is true, and a tag too few on the one + // frame a person is looking at is a row they cannot place. + for _, row := range r.chats { + if row.ID != "" && row.ID == strings.TrimSpace(mine.row.ID) { + r.folder = strings.TrimSpace(row.ProjectDir) + break + } + } r.wholeChats = len(r.chats) r.held = len(order) + len(r.chats) for _, key := range order { @@ -1129,7 +1157,7 @@ func (r tasksReading) paint(lines []tasksLine, i, width int, pal palette, lit bo if room < 1 { room = 1 } - return lead + kin + tasksChatRow(line, room, r.now, pal, lit) + return lead + kin + tasksChatRow(line, room, r.now, r.folder, r.tilde, pal, lit) case tasksLineTail: indent := strings.Repeat(" ", taskSheetPhoneIndent) tail := room - taskSheetPhoneIndent - ansi.StringWidth(line.kin) @@ -1442,7 +1470,7 @@ func tasksLabel(entry session.TaskIndexEntry) string { // AND IT NAMES ITS PROJECT ONCE. That fact used to be repeated on every row of // work the conversation ran, twenty-odd cells a row, on the rows whose names // were being cut to make room for it ([tasksFacts] drops it under here). -func tasksChatRow(line tasksLine, width int, now time.Time, pal palette, lit bool) string { +func tasksChatRow(line tasksLine, width int, now time.Time, folder, tilde string, pal palette, lit bool) string { chat := line.chat facts := make([]rowField, 0, 3) if !line.open && chat.kids > 0 { @@ -1451,8 +1479,14 @@ func tasksChatRow(line tasksLine, width int, now time.Time, pal palette, lit boo facts = append(facts, rowSay(tasksUnderWord(chat.kids))) } facts = append(facts, rowSay(sinceAt(chat.at, now))) - project := strings.TrimSpace(chat.row.Project) - if project != "" && project != chat.title { + // AND IT IS THE ONE RULE ABOUT PROJECT TAGS THAT DECIDES ([chatProjectWord], + // projecttag.go). This row used to name any folder whose word was not the + // conversation's own title, which put a lone `~` at the right of every chat + // opened in a home directory and the same project word down every row of the + // folder the person was already sitting in — both retired by the + // mission-control ruling, and both drawn here until this change. + folderOf := chatFolder{project: chat.row.Project, workspace: chat.row.Workspace, dir: chat.row.ProjectDir} + if project := chatProjectWord(folderOf, folder, tilde); project != "" && project != chat.title { facts = append(facts, rowSay(project)) } plan := rowPlan{primary: chat.title, fields: facts} @@ -1553,13 +1587,23 @@ type tasksFact struct { // A SHUT FOLD'S COUNT comes first because it is not a fact about the work at all // — it is the row saying that three more rows are behind it, which is the // difference between a page a person believes they have read and one they have -// not. The note is next because it is the only thing that can CORRECT the glyph — -// a claim of running with nobody behind it, or work happening in another window — -// and a row whose mark and whose words disagree is worse than a row missing a -// fact. The age follows, because every section here is named by time and the age -// is what a person scans down. Then WHERE THE WORK CAME FROM and WHAT IT IS -// DOING, which are the two facts a person acts on: whose it is, and whether it -// needs them. +// not. The note is next because it is the only thing that can say the work is +// happening SOMEWHERE ELSE, and a row whose mark and whose words disagree is +// worse than a row missing a fact. +// +// THEN THE STATE, AND IT IS AHEAD OF THE AGE AND THE CONVERSATION BECAUSE IT IS +// WHAT THE LIST IS READ FOR. A list of work answers one question before it +// answers any other — does this need me — and the word that answers it is the +// one thing on the row that must survive a narrow frame (tasktier.go's +// [tierRowParts] states the same law for the column beside a conversation: it is +// cut from the right and the verb is never what goes). It sat FIFTH for a while, +// behind the age and the conversation, and the cost of that was exact: at +// eighty-five columns a row nothing was running kept `The Skill Model Rebuild` +// and dropped `incomplete`, so the page named the chat and hid the one fact a +// person would have acted on. +// +// The age follows, because every section here is named by time and the age is +// what a person scans down, and then WHERE THE WORK CAME FROM. // // AND MONEY IS LAST. It used to sit fourth, in front of the owner and the state, // and it was also the only fact on the row with an ink of its own — so `$3.10` @@ -1602,9 +1646,9 @@ func tasksFacts(line tasksLine, now time.Time, pal palette) []tasksFact { return []tasksFact{ {field: fold}, {field: rowSay(tasksNote(item))}, + {field: tasksMiddleField(item)}, {field: tasksAgeField(item, now)}, {field: rowSay(source)}, - {field: tasksMiddleField(entry)}, {field: money, ink: placeMoneyInk(pal)}, } } @@ -1718,29 +1762,48 @@ func tasksEntryStamp(item tasksItem, now time.Time) time.Time { return item.entry.EndedAt } -// tasksMiddleField is what came of the work, in two spellings: everything the -// record can say about it, and the count of files alone. +// tasksMiddleField is what came of the work, in up to three spellings: the +// files and the whole state-and-reason, the state-and-reason alone, and the +// state word by itself. // -// LAW 2: A FACT DEGRADES BEFORE IT DISAPPEARS. `2 files · Annual is the default -// and the monthly price stays visible beside it.` is ninety cells of detail -// that used to end the tail and eat the name beside it; `2 files` is the same -// fact at seven, and it survives three widths further down. -func tasksMiddleField(entry session.TaskIndexEntry) rowField { - full := tasksMiddle(entry) +// LAW 2: A FACT DEGRADES BEFORE IT DISAPPEARS — AND WHAT IT DEGRADES TO IS THE +// HALF THE ROW IS READ FOR. A list of work is scanned to find out whether +// anything needs a person, so the reason gives way before the word and the word +// gives way last of all; `2 files · your call · nobody could check it` is the +// same fact at `your call` when the frame has eleven cells for it, and a row +// cut the other way would have spent them saying how many files a task nobody +// can act on touched. +func tasksMiddleField(item tasksItem) rowField { + full := tasksMiddle(item) if full == "" { return rowSay() } - if entry.FilesChanged > 0 { - if short := itoa(entry.FilesChanged) + plural(" file", entry.FilesChanged); short != full { - return rowSay(full, short) - } + // A LIVE ROW'S ACTIVITY IS A DIFFERENT FACT AND NOT A LONGER SPELLING OF THIS + // ONE. `18 of 40 steps` and `working` answer two questions, and a ladder that + // stepped from one to the other would be law 2 spent on something it does not + // mean — a fact degrades to a SHORTER SPELLING OF ITSELF, never to its + // neighbour ([tasksMiddle] returns the activity whole when there is one). + if strings.TrimSpace(item.entry.Activity) != "" { + return rowSay(full) } - // A FAILED ROW'S SHORT SPELLING IS ITS STATE WORD, which is the word its own - // page says ([tasksMiddle] joins the two). - if word := taskStateWord(entry, false); strings.HasPrefix(full, word+rowSep) { - return rowSay(full, word) + status := item.status() + word, whole := strings.TrimSpace(status.Word), strings.TrimSpace(status.RowWord()) + // THE RUNGS ARE WHAT IS LEFT AFTER THE DUPLICATES GO, and they are built by + // adding rather than by a ladder of cases: a row with no files has no long + // spelling, a state with no reason has no middle one, and a row with neither + // is one rung. A rung equal to the one above it is not a rung — the fitter + // would spend a width on a spelling that saves no cells — and a row that + // offered only its longest either fits or GOES, which is how a `your call` + // that changed no files came to draw nothing at all beyond its age on an + // ordinary hundred-column frame (rowfit.go's law 4 and its one caveat). + rungs := []string{full} + for _, shorter := range []string{whole, word} { + if shorter == "" || shorter == rungs[len(rungs)-1] { + continue + } + rungs = append(rungs, shorter) } - return rowSay(full) + return rowSay(rungs...) } // tasksCardHead is the first line of a phone card: the state and the name, in @@ -1760,7 +1823,7 @@ func tasksCardHead(item tasksItem, width int, pal palette, lit bool) string { // it, so a card with nothing to add stays one line. func tasksCardTail(item tasksItem, now time.Time) string { var segs []string - if middle := tasksMiddle(item.entry); middle != "" { + if middle := tasksMiddle(item); middle != "" { segs = append(segs, middle) } if note := tasksNote(item); note != "" { @@ -1774,14 +1837,19 @@ func tasksCardTail(item tasksItem, now time.Time) string { return strings.Join(segs, railSep) } -// tasksNote is what one row says about WHERE the work is, or about a claim of -// running with nobody behind it. +// tasksNote is what one row says about WHERE the work is, and NOTHING ABOUT +// WHAT STATE IT IS IN. // -// THE RECORD IS A FILE AND THE FILE CANNOT CORRECT ITSELF (taskview.go states -// the law). A row takes the word `running` when the work starts and nothing -// rewrites it, so a window that was killed leaves rows claiming a present that -// ended hours ago. What is drawn is [taskRecordStoppedWord] — not a judgement -// about the work, only the fact that the window went. +// IT USED TO SAY BOTH, AND THAT IS HOW ONE ROW CAME TO SAY ITS STATE TWICE. A +// row that claims to be running with nobody behind it took this page's own copy +// of the word ([taskRecordStoppedWord], which is still what the room's orchestrate +// line spends and is no longer where a ROW gets it), while [tasksMiddle] put the +// row's own state word in front of the outcome +// a few cells later — two facts, one reading, and a page drawing `stopped · +// stopped`. THE STATE WORD IS SAID ONCE, BY THE ONE FACT THAT IS ABOUT THE +// STATE ([tasksMiddle] reads [session.TaskStatus.RowWord], which is the engine's +// own join of the word and its reason). What is left here is the fact nothing +// else on the row can say: which window the work is happening in. func tasksNote(item tasksItem) string { if item.away { // A CONVERSATION OF THIS TERMINAL IS NOT `another window`. It arrives @@ -1794,9 +1862,6 @@ func tasksNote(item tasksItem) string { } return taskAwayNote(item.window) } - if item.entry.Live() && !item.runs { - return taskRecordStoppedWord - } return "" } @@ -1812,36 +1877,45 @@ func tasksEntryAt(entry session.TaskIndexEntry, now time.Time) time.Time { return entry.EndedAt } -func tasksMiddle(entry session.TaskIndexEntry) string { +// tasksMiddle is WHAT CAME OF THE WORK: what it touched, and the row's state +// with the reason behind it — said ONCE, by the one mechanism that punctuates +// that pair. +// +// THE WORD AND ITS REASON ARE JOINED BY THE ENGINE AND NEVER HERE. This page +// used to compose the pair itself, three different ways: the raw outcome for a +// refused landing, [session.TaskReasonOf] for one that carried no account of +// itself, and [taskStateWord] with the outcome hung off it for everything else. +// The third was the defect on the screenshot. A stopped node's own record spells +// its outcome `stopped` (internal/session's taskGradeOutcome), so the row read +// `stopped · stopped` — a page saying one fact twice because two writers each +// thought they were the one saying it. +// +// [session.TaskStatus.RowWord] is that join, written down once: the word, the +// reason, and the rule that a reason merely restating the word is not said +// again. It is what the column beside a conversation already reads (tasktier.go +// and its [tierRowBody]), so the two surfaces say the same sentence about one +// row by construction rather than by agreement. +// +// AND THE ROW'S RAW OUTCOME IS NOT ON IT. A landing's own sentence is the +// record's, and it is read on the card one keypress away; hung on the row it was +// ninety cells of detail eating the name beside it, and on every ROW THE ENGINE +// GRADES it was the state word again in different clothes. +func tasksMiddle(item tasksItem) string { + entry := item.entry if activity := strings.TrimSpace(entry.Activity); activity != "" { return activity } - if entry.Status == string(session.TaskFailed) && refused(entry.Ending) { - if outcome := strings.TrimSpace(entry.Outcome); outcome != "" { - return outcome - } - // THE REASON IS THE ENGINE'S SENTENCE AND NOT THIS PAGE'S. A row whose - // record carried no account of itself still says why it ended, in the one - // spelling of that table there is ([session.TaskReasonOf]). - return session.TaskReasonOf(entry.Ending, "") - } - if entry.Status == string(session.TaskFailed) && strings.TrimSpace(entry.Outcome) != "" { - // ONE STATE WORD ON BOTH SURFACES. The row used to read `gave up, said - // why` where its own page, one keypress away, opened `failed · landed 8d - // ago`, and a person who filed the row under one of those words could not - // find it under the other. The word is [taskStateWord]'s, which is where - // the page takes it from too, and the reason follows it rather than being - // promised — a promise of a reason is a row you have to open to read. - return taskStateWord(entry, false) + rowSep + strings.TrimSpace(entry.Outcome) - } var parts []string if entry.FilesChanged > 0 { parts = append(parts, itoa(entry.FilesChanged)+plural(" file", entry.FilesChanged)) } - if outcome := strings.TrimSpace(entry.Outcome); outcome != "" { - parts = append(parts, outcome) + // THE READING IS THE ROW'S OWN ([tasksItem.status]), which is the same one + // the mark in front of it is drawn from — a row whose glyph and whose words + // came from two readings is a row that can contradict itself. + if word := item.status().RowWord(); word != "" { + parts = append(parts, word) } - return strings.Join(parts, " · ") + return strings.Join(parts, rowSep) } // tasksGlyph is one roster row's cell and the hue it is said in, and IT IS THE diff --git a/internal/tui3/tasksplace_test.go b/internal/tui3/tasksplace_test.go index d18f7ee1f8..2bd67cbb11 100644 --- a/internal/tui3/tasksplace_test.go +++ b/internal/tui3/tasksplace_test.go @@ -504,8 +504,15 @@ func TestARunningParentKeepsItsRefusedChildUnderIt(t *testing.T) { if got := taskStateWord(line.item.entry, line.item.runs); got != taskRecordStoppedWord { t.Fatalf("refused child state = %q, want %q", got, taskRecordStoppedWord) } - if got := tasksMiddle(line.item.entry); got != rows[1].Outcome { - t.Fatalf("refused child reason = %q, want %q", got, rows[1].Outcome) + // AND THE REASON IS THE ENGINE'S SENTENCE AND NOT THE RECORD'S RAW + // OUTCOME. The row used to print [session.TaskIndexEntry.Outcome] + // straight through for a refused landing, which is one of the three + // hand-rolled compositions [tasksMiddle] replaced with + // [session.TaskStatus.RowWord] — the join that says the word once and + // drops a reason that merely restates it. + want := line.item.status().RowWord() + if got := tasksMiddle(line.item); got != want { + t.Fatalf("refused child reason = %q, want %q", got, want) } } } @@ -634,8 +641,8 @@ func TestTheFactsOnATaskRowAreJoinedByOneSeparator(t *testing.T) { world, win, now := tasksPolishFixture() reading := readTasks(world, tasksMine{}, win, time.Time{}, now) const name = "Put the annual toggle on the pricing page" - // AND THEY DEGRADE BY SPELLING RATHER THAN BY ENDING THE TAIL (law 2): the - // detail sentence is seventy-seven cells at its longest and seven at its + // AND THEY DEGRADE BY SPELLING RATHER THAN BY ENDING THE TAIL (law 2): what + // the work came to is `2 files · done` at its longest and `done` at its // shortest, and each width says the longest it has room for. // // THE SPEND IS LAST AND IS THEREFORE THE FIRST THING GIVEN UP. It used to sit @@ -643,16 +650,22 @@ func TestTheFactsOnATaskRowAreJoinedByOneSeparator(t *testing.T) { // what the work did — so a narrow frame kept the figure and dropped the two // facts a person acts on, on the one row of the page with an ink of its own. wide := tasksDrawnRow(tasksPage(reading, 160), name) - if !strings.HasSuffix(wide, "5h · 2 files · Annual is the default and the monthly price stays visible beside it. · $0.27") { + if !strings.HasSuffix(wide, "2 files · done · 5h · $0.27") { t.Fatalf("at 160 columns the row reads\n %s\nand the spend belongs after what the work did", wide) } + narrow := tasksDrawnRow(tasksPage(reading, 60), name) + if strings.Contains(narrow, "$0.27") { + t.Fatalf("at 60 columns the row kept its spend:\n %s", narrow) + } + // AND THE WORD IS THE LAST THING IT GIVES UP. A list of work is read to find + // out whether anything needs a person, so the file count goes before the + // state does ([tasksMiddleField] ranks the spellings). + if !strings.Contains(narrow, "done") || strings.Contains(narrow, "2 files") { + t.Fatalf("at 60 columns the row reads\n %s\nand the file count should have given way to the word", narrow) + } for _, width := range []int{120, 80} { - row := tasksDrawnRow(tasksPage(reading, width), name) - if strings.Contains(row, "$0.27") { - t.Fatalf("at %d columns the row kept its spend:\n %s", width, row) - } if !strings.Contains(plain(tasksPage(reading, width)), "The Annual Toggle") { - t.Fatalf("at %d columns the row gave up the conversation it came out of:\n %s", width, row) + t.Fatalf("at %d columns the page gave up the conversation the work came out of", width) } } } @@ -806,7 +819,7 @@ func TestTheListAndThePageSayOneWordAboutWorkThatFailed(t *testing.T) { EndedAt: now.AddDate(0, 0, -8), Outcome: "the package manager refused the archive", } word := taskStateWord(entry, false) - if got := tasksMiddle(entry); !strings.HasPrefix(got, word+rowSep) { + if got := tasksMiddle(tasksItem{entry: entry}); !strings.HasPrefix(got, word+rowSep) { t.Fatalf("the list says\n %s\nand its own page says\n %s\nwhich must be the first word of both", got, word) } a := &app{pal: newPalette(tokens.NoColor, false)} diff --git a/internal/tui3/tasksplacewords_test.go b/internal/tui3/tasksplacewords_test.go new file mode 100644 index 0000000000..4e0915d7a8 --- /dev/null +++ b/internal/tui3/tasksplacewords_test.go @@ -0,0 +1,180 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/aforge-v2/internal/session" + "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" +) + +// A ROW SAYS ITS STATE ONCE, WHOEVER WROTE THE RECORD IT IS READ FROM. +// +// The engine grades a node somebody stopped with the outcome `stopped` +// (internal/session's taskGradeOutcome), and this page used to hang that +// outcome off its own state word — so the row drew `stopped · stopped`, one +// reading said twice because two writers each thought they were the one saying +// it. [session.TaskStatus.RowWord] is the join now, and it is the only place +// the word and its reason are punctuated together. +func TestARowSaysItsStateWordOnce(t *testing.T) { + now := time.Date(2026, time.September, 11, 9, 0, 0, 0, time.UTC) + for _, tc := range []struct { + why string + entry session.TaskIndexEntry + runs bool + forbids string + }{{ + why: "a stopped node whose record grades it `stopped`", + entry: session.TaskIndexEntry{ + ID: "1", Label: "Upgraded model v2", Title: "Upgraded model v2", SessionID: "room-a", + Status: string(session.TaskFailed), Ending: session.TaskEndingStopped, + Outcome: "stopped", EndedAt: now.Add(-time.Hour), + }, + forbids: "stopped" + rowSep + "stopped", + }, { + why: "a row that claims to be running with nobody behind it", + entry: session.TaskIndexEntry{ + ID: "2", Label: "Fit the OU skill model", Title: "Fit the OU skill model", SessionID: "room-a", + Status: string(session.TaskRunning), + }, + forbids: taskRecordStoppedWord + rowSep + taskRecordStoppedWord, + }} { + item := tasksItem{entry: tc.entry, runs: tc.runs} + row := plain(tasksRow(tasksLine{kind: tasksLineTask, item: item}, 120, now, newPalette(tokens.NoColor, false), false)) + word := taskStateWord(tc.entry, tc.runs) + if strings.Contains(row, tc.forbids) { + t.Fatalf("%s draws\n %s\nand says %q twice", tc.why, row, word) + } + if strings.Count(row, word) != 1 { + t.Fatalf("%s draws\n %s\nand says %q %d times, want once", tc.why, row, word, strings.Count(row, word)) + } + } +} + +// THE ONE JOIN IS THE ENGINE'S. A surface that composed the pair itself would be +// a second spelling of it, which is how the two halves came to disagree. +func TestTheRowTakesItsStateAndReasonFromTheOneJoin(t *testing.T) { + now := time.Date(2026, time.September, 11, 9, 0, 0, 0, time.UTC) + entry := session.TaskIndexEntry{ + ID: "3", Label: "Wire the seam", Title: "Wire the seam", SessionID: "room-a", + Status: string(session.TaskFailed), Ending: session.TaskEndingWire, + Outcome: "the call never came back", FilesChanged: 2, EndedAt: now.Add(-time.Hour), + } + item := tasksItem{entry: entry} + want := "2 files" + rowSep + item.status().RowWord() + if got := tasksMiddle(item); got != want { + t.Fatalf("the row reads %q, want %q", got, want) + } +} + +// A CONVERSATION ROOT NAMES ITS FOLDER ONLY WHERE THE FOLDER IS NEWS. +// +// The mission-control ruling retired `~` as a project name and the project tag +// on rows in this window's own folder (docs/design/home-mission-control/DESIGN.md +// §1, "What is retired"). This page drew both until #884: a chat opened in a +// home directory arrived wearing a lone `~` at the right of its name. +func TestAConversationRootWearsAFolderTagOnlyWhereItIsNews(t *testing.T) { + now := time.Date(2026, time.September, 11, 9, 0, 0, 0, time.UTC) + pal := newPalette(tokens.NoColor, false) + draw := func(chat tasksChat, folder string) string { + line := tasksLine{kind: tasksLineChat, chat: chat} + return plain(tasksChatRow(line, 100, now, folder, "/home/pat", pal, false)) + } + root := func(title, project, dir string) tasksChat { + return tasksChat{ + key: tasksChatKey("room-a"), + row: session.SessionRow{ID: "room-a", Title: title, Project: project, ProjectDir: dir, Workspace: dir}, + title: title, at: now.Add(-time.Hour), + } + } + if got := draw(root("Clever Bet Prediction Model", "~", "/home/pat"), "/home/pat/code/pricing"); strings.Contains(got, "~") { + t.Fatalf("a chat in the home directory still wears a tag:\n %s", got) + } + if got := draw(root("Crafting a Multi-Page Website", "pricing", "/home/pat/code/pricing"), "/home/pat/code/pricing"); strings.Contains(got, "pricing") { + t.Fatalf("a chat in this window's own folder still wears its tag:\n %s", got) + } + if got := draw(root("AI Influencers", "aforge", "/home/pat/code/aforge"), "/home/pat/code/pricing"); !strings.Contains(got, "aforge") { + t.Fatalf("another project's chat lost the one fact that places it:\n %s", got) + } +} + +// AND THE WINDOW'S OWN FOLDER IS READ OFF ITS OWN CONVERSATION ROW. +// +// [app.taskSheetSelfRow] knows what this conversation is CALLED and nothing +// about which project bucket it belongs to; the world scan knows the bucket and +// nothing about a journal this session has not finished writing. The two meet in +// [tasksConversationRows], so the reading takes the folder from there — asking +// [tasksMine.row] directly got the half with no folder on it, and every row of +// the folder a person was sitting in went on wearing its name. +func TestTheReadingKnowsTheFolderThisWindowIsSittingIn(t *testing.T) { + now := time.Date(2026, time.September, 11, 9, 0, 0, 0, time.UTC) + mine := tasksMine{row: session.SessionRow{ + ID: "room-a", Title: "Sweeping the Frame Budget", + Transcript: "/tmp/room-a/transcript.jsonl", Open: true, Live: true, + }} + world := session.World{Projects: []session.Project{{ + Name: "aforge", Sessions: []session.SessionRow{{ + ID: "room-a", Title: "Sweeping the Frame Budget", Transcript: "/tmp/room-a/transcript.jsonl", + Project: "aforge", ProjectDir: "/home/pat/code/aforge", At: now.Add(-time.Hour), + }}, + }}, Read: now} + r := readTasks(world, mine, session.LastDays(now, 14), time.Time{}, now) + if r.folder != "/home/pat/code/aforge" { + t.Fatalf("the reading thinks this window is in %q, want the folder its own conversation names", r.folder) + } + // AND THE PAGE DOES NOT DRAW THAT FOLDER. The conversation's own title is on + // the page and its project's name is not, which is the whole of the rule. + if page := tasksPage(r, 100); strings.Contains(page, "aforge") { + t.Fatalf("the page drew the folder it is already in:\n%s", page) + } +} + +// THE WORD IS THE LAST THING A ROW GIVES UP, and a list of work is read to find +// out whether anything needs a person. +// +// Two rows lost it before this, at ordinary widths, and for two different +// reasons. A row nobody is behind said `incomplete` in the NOTE field, which +// ranked second — then the word moved into the fact that ranked fifth, behind the +// age and the conversation the work came out of, so at eighty-five columns the +// page kept `The Skill Model Rebuild` and dropped the one fact anybody would have +// acted on. And a `your call` row that changed no files had only ONE spelling to +// offer, because its file count and its state word collapsed into the same +// string — and a field with one spelling either fits or goes (rowfit.go, law 4), +// so a hundred-column frame drew its age and nothing else. +func TestTheStateWordIsTheLastThingARowGivesUp(t *testing.T) { + now := time.Date(2026, time.September, 11, 9, 0, 0, 0, time.UTC) + pal := newPalette(tokens.NoColor, false) + for _, tc := range []struct { + why string + item tasksItem + }{{ + why: "a row that claims to be running with nobody behind it", + item: tasksItem{ + entry: session.TaskIndexEntry{ + ID: "1", SessionID: "room-a", Status: string(session.TaskRunning), + Label: "Rebuild the skill model from the 2024 backtest", + }, + row: session.SessionRow{ID: "room-a", Title: "The Skill Model Rebuild"}, + }, + }, { + why: "a landing nobody could check that changed no files", + item: tasksItem{ + entry: session.TaskIndexEntry{ + ID: "2", SessionID: "room-a", Status: string(session.TaskUnverified), + Label: "Put the annual toggle on the pricing page and check the copy", + EndedAt: now.Add(-time.Hour), + }, + row: session.SessionRow{ID: "room-a", Title: "The Skill Model Rebuild"}, + }, + }} { + word := taskStateWord(tc.item.entry, tc.item.runs) + for _, width := range []int{120, 100, 90, 85, 80} { + row := plain(tasksRow(tasksLine{kind: tasksLineTask, item: tc.item}, width, now, pal, false)) + if !strings.Contains(row, word) { + t.Fatalf("at %d columns %s reads\n %s\nand has given up %q, which is the one thing the list is read for", + width, tc.why, strings.TrimRight(row, " "), word) + } + } + } +}